From 6bfe0b84228926b4909cd71aa7f62de29d776914 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 12 Mar 2026 01:34:00 +0000 Subject: [PATCH] Default Gemini adapter to yolo mode and add API access prompt note Gemini CLI only registers run_shell_command in --approval-mode yolo. Non-yolo modes don't expose it at all, making Paperclip API calls impossible. Always pass --approval-mode yolo and remove the now-unused policy engine code, approval mode config, and UI toggles. Add a "Paperclip API access note" to the prompt with curl examples via run_shell_command, since the universal SKILL.md is tool-agnostic. Also extract structured question events from Gemini assistant messages to support interactive approval flows. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-utils/src/types.ts | 8 ++++ packages/adapters/gemini-local/src/index.ts | 1 - .../gemini-local/src/server/execute.ts | 23 ++++++++-- .../adapters/gemini-local/src/server/parse.ts | 21 +++++++++ .../gemini-local/src/ui/build-config.ts | 2 +- .../__tests__/gemini-local-adapter.test.ts | 31 +++++++++++++ .../__tests__/gemini-local-execute.test.ts | 46 ++++++++++++++++++- .../adapters/gemini-local/config-fields.tsx | 15 ------ 8 files changed, 125 insertions(+), 22 deletions(-) diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 3ffbaec1..6503e5a1 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -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 { diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 3ceef32e..64b7b99f 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -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 diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 37a94232..4ffb51e3 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -59,6 +59,20 @@ function renderPaperclipEnvNote(env: Record): string { ].join("\n"); } +function renderApiAccessNote(env: Record): 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 { 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 { 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 { 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 | 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, }; } diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts index 1fd7ac65..a1ec6ddc 100644 --- a/packages/adapters/gemini-local/src/ui/build-config.ts +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -67,8 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record 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; diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts index 41da0530..5c36f6c3 100644 --- a/server/src/__tests__/gemini-local-adapter.test.ts +++ b/server/src/__tests__/gemini-local-adapter.test.ts @@ -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", () => { diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 92f8779a..92badecf 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -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; diff --git a/ui/src/adapters/gemini-local/config-fields.tsx b/ui/src/adapters/gemini-local/config-fields.tsx index a7302bfc..050c8d95 100644 --- a/ui/src/adapters/gemini-local/config-fields.tsx +++ b/ui/src/adapters/gemini-local/config-fields.tsx @@ -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({ - - isCreate - ? set!({ dangerouslyBypassSandbox: v }) - : mark("adapterConfig", "yolo", v) - } - /> ); }