From d1de8ee4ef1afa04068db6578e63fe35b8841f50 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Thu, 12 Mar 2026 20:02:11 +0100 Subject: [PATCH 1/3] fix: pass readOnly mode to JSON line agents in CLI batch spawner spawnAgent was dropping the readOnly flag when calling spawnJsonLineAgent, and spawnJsonLineAgent ignored it entirely. Now readOnlyArgs and readOnlyEnvOverrides from agent definitions are applied for all JSON line agents (Codex, OpenCode, Factory Droid) in read-only/plan mode. Co-Authored-By: Claude Opus 4.6 --- src/cli/services/agent-spawner.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 1f0d9fe84..400a0556d 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -355,7 +355,7 @@ async function spawnJsonLineAgent( cwd: string, prompt: string, agentSessionId?: string, - _readOnlyMode?: boolean + readOnlyMode?: boolean ): Promise { return new Promise((resolve) => { const env = buildExpandedEnv(); @@ -368,11 +368,17 @@ async function spawnJsonLineAgent( } } + // Apply read-only mode env overrides from agent definition + if (readOnlyMode && def?.readOnlyEnvOverrides) { + Object.assign(env, def.readOnlyEnvOverrides); + } + // Build args from agent definition const args: string[] = []; if (def?.batchModePrefix) args.push(...def.batchModePrefix); if (def?.batchModeArgs) args.push(...def.batchModeArgs); if (def?.jsonOutputArgs) args.push(...def.jsonOutputArgs); + if (readOnlyMode && def?.readOnlyArgs) args.push(...def.readOnlyArgs); if (agentSessionId && def?.resumeArgs) { args.push(...def.resumeArgs(agentSessionId)); @@ -496,7 +502,7 @@ export async function spawnAgent( } if (hasCapability(toolType, 'usesJsonLineOutput')) { - return spawnJsonLineAgent(toolType, cwd, prompt, agentSessionId); + return spawnJsonLineAgent(toolType, cwd, prompt, agentSessionId, readOnly); } return { From 85664cb48ef1abd661dc7745d6f3d94a35909963 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Thu, 12 Mar 2026 22:02:49 +0100 Subject: [PATCH 2/3] fix: enforce read-only mode for JSON line agents and pass customModel in CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for CLI batch/send agent spawning: 1. Pass readOnly flag to spawnJsonLineAgent (was silently dropped) 2. Skip YOLO/bypass args in read-only mode for all agents — they were overriding read-only flags (--dangerously-skip-permissions for Claude, --dangerously-bypass-approvals-and-sandbox for Codex, -y for Gemini) 3. Read customModel from agent session config and pass it via modelArgs so CLI-spawned agents use the model configured in the desktop UI Also fixes OpenCode read-only env overrides to keep blanket permission grants (prevents stdin hangs in batch mode) since --agent plan handles read-only enforcement at the CLI level. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/send.ts | 1 + src/cli/services/agent-spawner.ts | 29 +++++++++++++++++++++++++---- src/cli/services/batch-processor.ts | 4 +++- src/main/agents/definitions.ts | 6 ++++-- src/shared/types.ts | 1 + 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 57b8801e7..6c7bcce8d 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -110,6 +110,7 @@ export async function send( // Spawn agent — spawnAgent handles --resume vs --session-id internally const result = await spawnAgent(agent.toolType, agent.cwd, message, options.session, { readOnlyMode: options.readOnly, + customModel: agent.customModel, }); const response = buildResponse(agentId, agent.name, result, agent.toolType); diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 400a0556d..3199067d1 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -22,9 +22,11 @@ const CLAUDE_ARGS = [ '--verbose', '--output-format', 'stream-json', - '--dangerously-skip-permissions', ]; +// Permission bypass arg for Claude — skipped in read-only mode +const CLAUDE_YOLO_ARGS = ['--dangerously-skip-permissions']; + // Cached paths per agent type (resolved once at startup) const cachedPaths: Map = new Map(); @@ -192,6 +194,9 @@ async function spawnClaudeAgent( if (def?.readOnlyEnvOverrides) { Object.assign(env, def.readOnlyEnvOverrides); } + } else { + // Only bypass permissions in non-read-only mode + args.push(...CLAUDE_YOLO_ARGS); } if (agentSessionId) { @@ -355,7 +360,8 @@ async function spawnJsonLineAgent( cwd: string, prompt: string, agentSessionId?: string, - readOnlyMode?: boolean + readOnlyMode?: boolean, + customModel?: string ): Promise { return new Promise((resolve) => { const env = buildExpandedEnv(); @@ -376,9 +382,21 @@ async function spawnJsonLineAgent( // Build args from agent definition const args: string[] = []; if (def?.batchModePrefix) args.push(...def.batchModePrefix); - if (def?.batchModeArgs) args.push(...def.batchModeArgs); + + // In read-only mode, filter out YOLO/bypass args from batchModeArgs + // (they override read-only flags). In normal mode, apply all batchModeArgs. + if (def?.batchModeArgs) { + if (readOnlyMode && def.yoloModeArgs?.length) { + const yoloSet = new Set(def.yoloModeArgs); + args.push(...def.batchModeArgs.filter((a) => !yoloSet.has(a))); + } else { + args.push(...def.batchModeArgs); + } + } + if (def?.jsonOutputArgs) args.push(...def.jsonOutputArgs); if (readOnlyMode && def?.readOnlyArgs) args.push(...def.readOnlyArgs); + if (customModel && def?.modelArgs) args.push(...def.modelArgs(customModel)); if (agentSessionId && def?.resumeArgs) { args.push(...def.resumeArgs(agentSessionId)); @@ -483,6 +501,8 @@ export interface SpawnAgentOptions { agentSessionId?: string; /** Run in read-only/plan mode (uses centralized agent definitions for provider-specific flags) */ readOnlyMode?: boolean; + /** Custom model ID from agent config (e.g., 'github-copilot/gpt-5-mini') */ + customModel?: string; } /** @@ -496,13 +516,14 @@ export async function spawnAgent( options?: SpawnAgentOptions ): Promise { const readOnly = options?.readOnlyMode; + const customModel = options?.customModel; if (toolType === 'claude-code') { return spawnClaudeAgent(cwd, prompt, agentSessionId, readOnly); } if (hasCapability(toolType, 'usesJsonLineOutput')) { - return spawnJsonLineAgent(toolType, cwd, prompt, agentSessionId, readOnly); + return spawnJsonLineAgent(toolType, cwd, prompt, agentSessionId, readOnly, customModel); } return { diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 20e2e19a1..15b8f40e6 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -440,7 +440,9 @@ export async function* runPlaybook( } // Spawn agent with combined prompt + document - const result = await spawnAgent(session.toolType, session.cwd, finalPrompt); + const result = await spawnAgent(session.toolType, session.cwd, finalPrompt, undefined, { + customModel: session.customModel, + }); const elapsedMs = Date.now() - taskStartTime; diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 869383045..8ee7aee68 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -275,10 +275,12 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow","external_directory":"allow","question":"deny"},"tools":{"question":false}}', }, - // In read-only mode, strip blanket permission grants so the plan agent can't auto-approve file writes. + // In read-only mode, keep blanket permission grants to prevent stdin prompts that hang batch mode. + // Read-only enforcement comes from --agent plan (readOnlyArgs), not env config. // Keep question tool disabled to prevent stdin hangs in batch mode. readOnlyEnvOverrides: { - OPENCODE_CONFIG_CONTENT: '{"permission":{"question":"deny"},"tools":{"question":false}}', + OPENCODE_CONFIG_CONTENT: + '{"permission":{"*":"allow","external_directory":"allow","question":"deny"},"tools":{"question":false}}', }, // Agent-specific configuration options shown in UI configOptions: [ diff --git a/src/shared/types.ts b/src/shared/types.ts index 6641b60f1..7de98589f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -36,6 +36,7 @@ export interface SessionInfo { cwd: string; projectRoot: string; autoRunFolderPath?: string; + customModel?: string; } // Usage statistics from AI agent CLI (Claude Code, Codex, etc.) From 6dfb763555284e083faa18e10c422e789b9dceee Mon Sep 17 00:00:00 2001 From: chr1syy Date: Thu, 12 Mar 2026 22:30:49 +0100 Subject: [PATCH 3/3] fix: correct read-only mode test assertion and prevent Gemini CLI hang - Invert stale test asserting --dangerously-skip-permissions is present in read-only mode; add coverage for its presence in normal mode - Skip yolo-arg filtering for agents without CLI-level read-only enforcement (Gemini CLI needs -y to avoid interactive prompt hang with closed stdin) Co-Authored-By: Claude Opus 4.6 --- src/__tests__/cli/services/agent-spawner.test.ts | 5 ++++- src/cli/services/agent-spawner.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/__tests__/cli/services/agent-spawner.test.ts b/src/__tests__/cli/services/agent-spawner.test.ts index 548d90500..58d87c948 100644 --- a/src/__tests__/cli/services/agent-spawner.test.ts +++ b/src/__tests__/cli/services/agent-spawner.test.ts @@ -1176,7 +1176,8 @@ Some text with [x] in it that's not a checkbox expect(args).toContain('plan'); // Should still have base args expect(args).toContain('--print'); - expect(args).toContain('--dangerously-skip-permissions'); + // Should NOT have permission bypass in read-only mode + expect(args).not.toContain('--dangerously-skip-permissions'); mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); mockChild.emit('close', 0); @@ -1193,6 +1194,8 @@ Some text with [x] in it that's not a checkbox const [, args] = mockSpawn.mock.calls[0]; expect(args).not.toContain('--permission-mode'); expect(args).not.toContain('plan'); + // Should have permission bypass in normal mode + expect(args).toContain('--dangerously-skip-permissions'); mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); mockChild.emit('close', 0); diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 3199067d1..90ecb25d2 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -385,8 +385,10 @@ async function spawnJsonLineAgent( // In read-only mode, filter out YOLO/bypass args from batchModeArgs // (they override read-only flags). In normal mode, apply all batchModeArgs. + // Skip filtering for agents without CLI-level read-only enforcement + // (e.g., Gemini CLI needs -y to avoid interactive prompts that hang with closed stdin). if (def?.batchModeArgs) { - if (readOnlyMode && def.yoloModeArgs?.length) { + if (readOnlyMode && def.readOnlyCliEnforced !== false && def.yoloModeArgs?.length) { const yoloSet = new Set(def.yoloModeArgs); args.push(...def.batchModeArgs.filter((a) => !yoloSet.has(a))); } else {