Merge pull request #656 from aaaaron/gemini-tool-permissions

Default Gemini adapter to yolo mode, add API access note
This commit is contained in:
Dotta
2026-03-12 07:59:14 -05:00
committed by GitHub
8 changed files with 125 additions and 22 deletions

View File

@@ -75,6 +75,14 @@ export interface AdapterExecutionResult {
runtimeServices?: AdapterRuntimeServiceReport[];
summary?: string | null;
clearSession?: boolean;
question?: {
prompt: string;
choices: Array<{
key: string;
label: string;
description?: string;
}>;
} | null;
}
export interface AdapterSessionCodec {

View File

@@ -30,7 +30,6 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- promptTemplate (string, optional): run prompt template
- model (string, optional): Gemini model id. Defaults to auto.
- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default")
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
- command (string, optional): defaults to "gemini"
- extraArgs (string[], optional): additional CLI args

View File

@@ -59,6 +59,20 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
return [
"Paperclip API access note:",
"Use run_shell_command with curl to make Paperclip API requests.",
"GET example:",
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
"POST/PATCH example:",
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
"",
"",
].join("\n");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
@@ -132,7 +146,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
const command = asString(config.command, "gemini");
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
const sandbox = asBoolean(config.sandbox, false);
const workspaceContext = parseObject(context.paperclipWorkspace);
@@ -250,7 +263,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`);
notes.push("Added --approval-mode yolo for unattended execution.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
@@ -275,13 +288,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
context,
});
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--output-format", "stream-json"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
args.push("--approval-mode", "yolo");
if (sandbox) {
args.push("--sandbox");
} else {
@@ -398,6 +412,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
question: attempt.parsed.question,
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
};
};

View File

@@ -78,6 +78,7 @@ export function parseGeminiJsonl(stdout: string) {
let errorMessage: string | null = null;
let costUsd: number | null = null;
let resultEvent: Record<string, unknown> | null = null;
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
@@ -98,6 +99,25 @@ export function parseGeminiJsonl(stdout: string) {
if (type === "assistant") {
messages.push(...collectMessageText(event.message));
const messageObj = parseObject(event.message);
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
for (const partRaw of content) {
const part = parseObject(partRaw);
if (asString(part.type, "").trim() === "question") {
question = {
prompt: asString(part.prompt, "").trim(),
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
const choice = parseObject(choiceRaw);
return {
key: asString(choice.key, "").trim(),
label: asString(choice.label, "").trim(),
description: asString(choice.description, "").trim() || undefined,
};
}),
};
break; // only one question per message
}
}
continue;
}
@@ -154,6 +174,7 @@ export function parseGeminiJsonl(stdout: string) {
costUsd,
errorMessage,
resultEvent,
question,
};
}

View File

@@ -67,8 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo";
ac.sandbox = !v.dangerouslyBypassSandbox;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;

View File

@@ -39,6 +39,37 @@ describe("gemini_local parser", () => {
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
expect(parsed.errorMessage).toBe("model access denied");
});
it("extracts structured questions", () => {
const stdout = [
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I have a question." },
{
type: "question",
prompt: "Which model?",
choices: [
{ key: "pro", label: "Gemini Pro", description: "Better" },
{ key: "flash", label: "Gemini Flash" },
],
},
],
},
}),
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.summary).toBe("I have a question.");
expect(parsed.question).toEqual({
prompt: "Which model?",
choices: [
{ key: "pro", label: "Gemini Pro", description: "Better" },
{ key: "flash", label: "Gemini Flash", description: undefined },
],
});
});
});
describe("gemini_local stale session detection", () => {

View File

@@ -77,7 +77,6 @@ describe("gemini execute", () => {
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",
yolo: true,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
@@ -112,6 +111,51 @@ describe("gemini execute", () => {
);
expect(invocationPrompt).toContain("Paperclip runtime note:");
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
expect(invocationPrompt).toContain("Paperclip API access note:");
expect(invocationPrompt).toContain("run_shell_command");
expect(result.question).toBeNull();
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("always passes --approval-mode yolo", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
await execute({
runId: "run-yolo",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
},
context: {},
authToken: "t",
onLog: async () => {},
});
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv).not.toContain("--policy");
expect(capture.argv).not.toContain("--allow-all");
expect(capture.argv).not.toContain("--allow-read");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;

View File

@@ -2,7 +2,6 @@ import type { AdapterConfigFieldsProps } from "../types";
import {
DraftInput,
Field,
ToggleField,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
@@ -45,20 +44,6 @@ export function GeminiLocalConfigFields({
<ChoosePathButton />
</div>
</Field>
<ToggleField
label="Yolo mode"
hint="Run Gemini with --approval-mode yolo for unattended operation."
checked={
isCreate
? values!.dangerouslyBypassSandbox
: eff("adapterConfig", "yolo", config.yolo === true)
}
onChange={(v) =>
isCreate
? set!({ dangerouslyBypassSandbox: v })
: mark("adapterConfig", "yolo", v)
}
/>
</>
);
}