From be4fd80e6f43580179c709c2a80facd0e29eded5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:24:28 -0700 Subject: [PATCH 1/9] fix(native-chat): honor saved agent launch commands and arguments --- .../agent-launch-executor.test.ts | 4 +- .../agent-launch/agent-launch-executor.ts | 13 +- src/main/agent-launch/agent-launch-mode.ts | 4 +- .../claude-agent-sdk-contract-pins.test.ts | 16 ++- .../claude/claude-model-catalog-probe.test.ts | 6 +- .../claude-structured-launch-args.test.ts | 61 ++++++++++ .../claude/claude-structured-launch-args.ts | 73 +++++++++++ ...laude-structured-launch-resolution.test.ts | 31 ++++- .../claude-structured-launch-resolution.ts | 10 +- .../codex/codex-model-catalog-probe.test.ts | 9 +- .../codex-structured-launch-args.test.ts | 84 +++++++++++++ .../codex/codex-structured-launch-args.ts | 114 ++++++++++++++++++ ...codex-structured-launch-resolution.test.ts | 34 ++++-- .../codex-structured-launch-resolution.ts | 6 +- .../journal-submission-positions.test.ts | 1 - ...tructured-agent-command-resolution.test.ts | 92 ++++++++++++++ .../structured-agent-command-resolution.ts | 55 +++++++++ .../structured-agent-configured-args.test.ts | 47 ++++++++ .../structured-agent-configured-args.ts | 27 +++++ ...tured-agent-session-create-support.test.ts | 25 +++- ...structured-agent-session-create-support.ts | 6 +- .../runtime/orca-runtime-get-worktree-ps.ts | 10 +- ...-structured-launch-settings-wiring.test.ts | 42 +++++++ .../src/lib/agent-launch-route-input.test.ts | 13 +- .../src/lib/agent-launch-routing.test.ts | 2 +- src/shared/agent-launch-intent.ts | 6 +- src/shared/node-cli-command-resolution.ts | 21 ++++ .../tui-agent-launch-command-override.test.ts | 53 ++++++++ .../tui-agent-launch-command-override.ts | 38 ++++-- 29 files changed, 839 insertions(+), 64 deletions(-) create mode 100644 src/main/claude/claude-structured-launch-args.test.ts create mode 100644 src/main/claude/claude-structured-launch-args.ts create mode 100644 src/main/codex/codex-structured-launch-args.test.ts create mode 100644 src/main/codex/codex-structured-launch-args.ts create mode 100644 src/main/native-chat/structured-agent-command-resolution.test.ts create mode 100644 src/main/native-chat/structured-agent-command-resolution.ts create mode 100644 src/main/native-chat/structured-agent-configured-args.test.ts create mode 100644 src/main/native-chat/structured-agent-configured-args.ts create mode 100644 src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts create mode 100644 src/shared/tui-agent-launch-command-override.test.ts diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 210289785ace..7c044d316954 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -573,7 +573,7 @@ describe('caller-supplied launch inputs', () => { const result = await h.run({ agent: 'claude', target: EXISTING, agentArgs: '--model opus' }) expect(result.outcome.kind).toBe('structured') - expect(result.warning).toContain('does not apply launch arguments') + expect(result.warning).toContain('per-launch argument override was ignored') }) it('warns when a structured session ignored an explicit "no arguments" too', async () => { @@ -583,7 +583,7 @@ describe('caller-supplied launch inputs', () => { // The structured path reads the bypass-permissions bit from the user's SETTINGS default, so a // caller that asked for no arguments can still get a session with more permission than it asked // for. Staying silent about that is the failure mode worth a test. - expect(result.warning).toContain('does not apply launch arguments') + expect(result.warning).toContain('per-launch argument override was ignored') }) it('leaves a structured launch unwarned when it carried no arguments at all', async () => { diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index b33548abee03..0447c4c63666 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -292,16 +292,7 @@ async function createSurface( } /** - * A structured session cannot apply launch arguments, so a launch that carried some and got one - * anyway has to say so. - * - * Reported rather than routed around: the arguments field is a TUI concern by an explicit decision - * (`hasExplicitTuiLaunchCommand` reads the launch command and pointedly not the args, because the - * Agent SDK and app-server version their option sets independently of the interactive CLI), so - * downgrading here would override a stated user preference on the strength of a field that is not - * evidence about the surface. `null` warns too: "no arguments" is also unapplied, and the structured - * path still reads the bypass-permissions bit out of the user's *settings* default, so a caller that - * asked for none can get a session running with more permission than it requested. + * Structured chat uses saved Arguments, so a per-call override still needs a truthful warning. */ function ignoredStructuredAgentArgsWarning( intent: AgentLaunchIntent @@ -310,7 +301,7 @@ function ignoredStructuredAgentArgsWarning( ? undefined : { warning: - 'Started a structured chat session, which does not apply launch arguments; the requested arguments were ignored.' + 'Started a structured chat session using saved agent Arguments; the per-launch argument override was ignored.' } } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 0ef01c01da06..5168279c08cb 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -32,7 +32,7 @@ import { type StructuredNativeChatBlocker } from '../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../shared/tui-agent' -import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' +import { requiresTuiAgentLaunchCommand } from '../native-chat/structured-agent-command-resolution' import type { WorkspaceLaunchKind } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -160,7 +160,7 @@ export function decideAgentLaunchMode(args: { // when it names somewhere other than the workspace root, by the same shared rule. requiresTuiLaunchCommand: requestsCwdOutsideWorkspaceRoot(placement.workspacePath, placement.cwd) || - hasExplicitTuiLaunchCommand(settings, agent) + requiresTuiAgentLaunchCommand(settings, agent) }) if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index b08bf307734f..3a8e4ea494e2 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -342,7 +342,14 @@ describe('Claude Agent SDK contract pins', () => { const spawns: SpawnSeen[] = [] // Driven by the real resolver, so the argv walk covers its option set and merge order, // not a hand-written options literal. - const launch = await resolvedLaunch('bypassPermissions', ['--model', 'claude-sonnet-4-5']) + const launch = await resolvedLaunch('bypassPermissions', [ + '--model', + 'claude-sonnet-4-5', + '--dangerously-skip-permissions', + '--output-format', + 'text', + '--session-id=wrong-session' + ]) await drainQuery({ ...launch.options, pathToClaudeCodeExecutable: FAKE_CLI, @@ -359,9 +366,10 @@ describe('Claude Agent SDK contract pins', () => { expect(argv.filter((arg) => arg === '--dangerously-skip-permissions')).toHaveLength(1) expect(argv).not.toContain('--allow-dangerously-skip-permissions') expect(argv[argv.indexOf('--permission-mode') + 1]).toBe('default') - // Configured CLI arguments are a terminal concern; a record written before they stopped - // being read must not smuggle one back into the child's argv. - expect(argv).not.toContain('--model') + expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5') + expect(argv.filter((arg) => arg === '--model')).toHaveLength(1) + expect(argv.filter((arg) => arg === '--output-format')).toHaveLength(1) + expect(argv[argv.indexOf('--output-format') + 1]).toBe('stream-json') // Headless print mode is the SDK's only mode; `query()` never passes `-p`, // and if the SDK ever started passing it this pin would notice. const impliedByHeadlessQuery = new Set(['-p']) diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 6e1bd13e1b58..c47ef026e565 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe' import { resolveClaudeStructuredInvocation } from './claude-structured-launch-resolution' import type { discoverModelsLocal } from '../text-generation/commit-message-model-discovery' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' import type { SpawnedSourceControlAgentProcess, SpawnSourceControlAgent @@ -21,7 +22,10 @@ function probeDeps(): { resolveAuthPolicy: () => typeof AUTH_POLICY } { return { - resolveCommand: () => '/resolved/claude with spaces/claude', + resolveCommand: () => + resolveStructuredAgentCommand('claude', { + agentCmdOverrides: { claude: `"${process.execPath}"` } + }), resolveEnv: () => ({ ANTHROPIC_MODEL_GATEWAY: 'https://gateway.example' }), resolveInheritedEnv: async () => ({ PATH: '/resolved/bin', HOME: '/homes/user' }), resolveAuthPolicy: () => AUTH_POLICY diff --git a/src/main/claude/claude-structured-launch-args.test.ts b/src/main/claude/claude-structured-launch-args.test.ts new file mode 100644 index 000000000000..d6b6049a4dc3 --- /dev/null +++ b/src/main/claude/claude-structured-launch-args.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { claudeStructuredExtraArgs } from './claude-structured-launch-args' + +describe('Claude structured launch arguments', () => { + it('translates long flags, equals values, and the model short flag', () => { + expect( + claudeStructuredExtraArgs([ + '--model', + 'opus', + '--effort=high', + '-m', + 'sonnet', + '--chrome', + '--add-dir', + '/repo/other' + ]) + ).toEqual({ model: 'sonnet', effort: 'high', chrome: null, 'add-dir': '/repo/other' }) + }) + + it('removes SDK transport, permission, and lifecycle flags with their values', () => { + expect( + claudeStructuredExtraArgs([ + '-p', + '--input-format', + 'text', + '--output-format=json', + '--json-schema', + '{}', + '--verbose', + '-r', + 'other-session', + '-c', + '--session-id=other-session', + '--fork-session', + '--permission-mode', + 'bypassPermissions', + '--dangerously-skip-permissions', + '--allow-dangerously-skip-permissions', + '--permission-prompt-tool', + 'other', + '--replay-user-messages', + '--include-partial-messages', + '--setting-sources', + 'none', + '--system-prompt', + 'replacement', + '--append-system-prompt-file=other.txt', + '--model', + 'opus' + ]) + ).toEqual({ model: 'opus' }) + }) + + it('ignores positional prompts and tokens after --', () => { + expect( + claudeStructuredExtraArgs(['prompt', '--model', 'opus', '--', '--effort', 'high']) + ).toEqual({ + model: 'opus' + }) + }) +}) diff --git a/src/main/claude/claude-structured-launch-args.ts b/src/main/claude/claude-structured-launch-args.ts new file mode 100644 index 000000000000..780e716e0f96 --- /dev/null +++ b/src/main/claude/claude-structured-launch-args.ts @@ -0,0 +1,73 @@ +/** Flags supplied by the SDK or owned by Orca's structured transport. */ +const OWNED_FLAGS = new Set([ + 'print', + 'input-format', + 'output-format', + 'json-schema', + 'verbose', + 'resume', + 'continue', + 'session-id', + 'fork-session', + 'resume-session-at', + 'resume-drops-turn', + 'no-session-persistence', + 'session-mirror', + 'await-initialize', + 'permission-mode', + 'dangerously-skip-permissions', + 'allow-dangerously-skip-permissions', + 'permission-prompt-tool', + 'permission-prompts', + 'allowedTools', + 'disallowedTools', + 'replay-user-messages', + 'include-partial-messages', + 'setting-sources', + 'system-prompt', + 'system-prompt-file', + 'append-system-prompt', + 'append-system-prompt-file' +]) + +const SHORT_FLAGS: Record = { + '-m': 'model', + '-d': 'debug', + '-p': 'print', + '-r': 'resume', + '-c': 'continue', + '-h': 'help', + '-v': 'version' +} + +/** Translate a pinned CLI argv into the SDK's `--key [value]` extraArgs shape. */ +export function claudeStructuredExtraArgs(args: readonly string[]): Record { + const extraArgs: Record = {} + for (let index = 0; index < args.length; index++) { + const arg = args[index]! + if (arg === '--') { + break + } + + const separator = arg.indexOf('=') + const rawFlag = separator === -1 ? arg : arg.slice(0, separator) + const flag = rawFlag.startsWith('--') ? rawFlag.slice(2) : SHORT_FLAGS[rawFlag] + if (!flag || flag === 'help' || flag === 'version') { + continue + } + + const inlineValue = separator === -1 ? undefined : arg.slice(separator + 1) + const next = args[index + 1] + const hasSeparateValue = + inlineValue === undefined && next !== undefined && next !== '--' && !next.startsWith('-') + if (hasSeparateValue) { + index++ + } + if (OWNED_FLAGS.has(flag)) { + continue + } + + extraArgs[flag] = inlineValue ?? (hasSeparateValue ? next! : null) + } + return extraArgs +} diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 9db732d3430d..a3489688c6a3 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -289,18 +289,39 @@ describe('claude structured launch resolution', () => { } ) - // The configured CLI arguments are a terminal concern: a durable record written before they - // stopped being read must not smuggle one back into the child. - it("ignores the record's durable launch arguments", async () => { + it('passes configured arguments on start without taking over permission or session flags', async () => { const launch = await resolverFor( record({ - launchArgs: ['--model', 'claude-sonnet-4-5', '--dangerously-skip-permissions'] + launchArgs: [ + '--model', + 'claude-sonnet-4-5', + '--dangerously-skip-permissions', + '--resume=wrong-session', + '--permission-mode', + 'bypassPermissions' + ] }) )({ identity: IDENTITY }) expect(launch.options.model).toBeUndefined() - expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) + expect(launch.options.extraArgs).toEqual({ + model: 'claude-sonnet-4-5', + 'replay-user-messages': null + }) expect(launch.options.permissionMode).toBeUndefined() + expect(launch.options.sessionId).toBe(launch.providerSessionId) + }) + + it('passes configured arguments when resuming a transcript', async () => { + const launch = await resolverFor( + record({ ...RESUMABLE, launchArgs: ['--effort', 'high', '-r', 'wrong-session'] }) + )({ identity: identityAt('leaf-current') }) + + expect(launch.options.resume).toBe('provider-current') + expect(launch.options.extraArgs).toEqual({ + effort: 'high', + 'replay-user-messages': null + }) }) it('keeps the session launch environment pinned after account settings change', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 979b5ae5963e..4ddaf941e50e 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -33,6 +33,7 @@ import { import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' +import { claudeStructuredExtraArgs } from './claude-structured-launch-args' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const @@ -287,8 +288,7 @@ export function createClaudeStructuredLaunchResolver( providerSessionId, claudeConfigDir: record.accountHome.path }))) - // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal - // concern, and the permission mode they used to smuggle in is an owned provider option now. + const configuredArgs = claudeStructuredExtraArgs(record.launchArgs ?? []) const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) @@ -305,7 +305,11 @@ export function createClaudeStructuredLaunchResolver( options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, - extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs }, + extraArgs: { + ...configuredArgs, + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + ...permission.extraArgs + }, // Claude owns where a resumed conversation continues; the stored leaf is Orca's bookkeeping. ...(resumesTranscript ? { resume: providerSessionId } : { sessionId: providerSessionId }) }, diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index 87c615d410bb..2b2951b3309a 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { createCodexModelCatalogProbe } from './codex-model-catalog-probe' import { resolveCodexStructuredInvocation } from './codex-structured-launch-resolution' import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' const MODEL_ROW = { model: 'gpt-live', @@ -23,7 +24,13 @@ describe('codex model catalog probe', () => { const resolveCommand = vi.fn((options?: { pathEnv?: string | null; homePath?: string }) => { expect(options?.pathEnv).toBe('/resolved/bin') expect(options?.homePath).toBe('/homes/user') - return '/resolved/bin/codex' + return resolveStructuredAgentCommand( + 'codex', + { + agentCmdOverrides: { codex: `"${process.execPath}"` } + }, + options + ) }) const invocations: CodexAppServerInvocation[] = [] const probe = createCodexModelCatalogProbe({ diff --git a/src/main/codex/codex-structured-launch-args.test.ts b/src/main/codex/codex-structured-launch-args.test.ts new file mode 100644 index 000000000000..5e6e1e9c8f10 --- /dev/null +++ b/src/main/codex/codex-structured-launch-args.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { codexStructuredLaunchArgs } from './codex-structured-launch-args' + +describe('codexStructuredLaunchArgs', () => { + it('preserves root config and feature option order', () => { + expect( + codexStructuredLaunchArgs([ + '-c', + 'model_reasoning_effort=high', + '--enable', + 'unified_exec', + '--config=web_search="live"', + '--disable=some_feature', + '-m', + 'gpt-5.6-sol' + ]) + ).toEqual([ + '-c', + 'model_reasoning_effort=high', + '--enable', + 'unified_exec', + '--config=web_search="live"', + '--disable=some_feature', + '-m', + 'gpt-5.6-sol' + ]) + }) + + it('drops permission options and config overrides owned by Orca', () => { + expect( + codexStructuredLaunchArgs([ + '--dangerously-bypass-approvals-and-sandbox', + '-a', + 'never', + '--sandbox=danger-full-access', + '--approve-for-me', + '-c', + 'approval_policy="never"', + '--config=sandbox_mode="danger-full-access"', + '-c', + 'sandbox_workspace_write.writable_roots=["/tmp"]', + '-c', + '"sandbox_workspace_write".writable_roots=["/tmp"]', + '-c', + '\'approval_policy\'="never"', + '-c', + 'model_reasoning_effort=high' + ]) + ).toEqual(['-c', 'model_reasoning_effort=high']) + }) + + it('drops a profile and TUI-only path options without leaving their values as prompts', () => { + expect( + codexStructuredLaunchArgs([ + '--profile', + 'review', + '-pother', + '--cd', + '/another/repo', + '--worktree', + '-c', + 'model_reasoning_effort=high' + ]) + ).toEqual(['-c', 'model_reasoning_effort=high']) + }) + + it('discards operands after the option terminator', () => { + expect( + codexStructuredLaunchArgs(['-c', 'model_reasoning_effort=high', '--', 'a prompt']) + ).toEqual(['-c', 'model_reasoning_effort=high']) + }) + + it.each([ + { tokens: ['a prompt'] }, + { tokens: ['app-server'] }, + { tokens: ['--remote', 'wss://host'] }, + { tokens: ['--remote=wss://host'] }, + { tokens: ['--remote-auth-token-env', 'TOKEN'] }, + { tokens: ['--unknown-flag'] }, + { tokens: ['--enable'] } + ])('rejects unsafe or incomplete arguments: %j', ({ tokens }) => { + expect(() => codexStructuredLaunchArgs(tokens)).toThrow() + }) +}) diff --git a/src/main/codex/codex-structured-launch-args.ts b/src/main/codex/codex-structured-launch-args.ts new file mode 100644 index 000000000000..ff3b20d4685a --- /dev/null +++ b/src/main/codex/codex-structured-launch-args.ts @@ -0,0 +1,114 @@ +// Only root CLI options can precede `app-server`. A prompt or another subcommand +// would change the process Orca starts, and permission options belong to Orca. +import { parseTomlKeyPath } from './config-toml-key-path' + +const VALUE_OPTIONS = new Set([ + '-c', + '--config', + '--enable', + '--disable', + '-m', + '--model', + '--local-provider' +]) + +const DROPPED_VALUE_OPTIONS = new Set([ + '-a', + '--ask-for-approval', + '-s', + '--sandbox', + '-p', + '--profile', + '-C', + '--cd', + '--add-dir', + '-i', + '--image' +]) + +const DROPPED_SWITCHES = new Set([ + '--dangerously-bypass-approvals-and-sandbox', + '--yolo', + '--approve-for-me', + '--not-so-yolo', + '--dangerously-bypass-hook-trust', + '--worktree', + '--strict-config', + '--help', + '-h', + '--version', + '-V' +]) + +const PASSTHROUGH_SWITCHES = new Set(['--search', '--oss', '--no-alt-screen', '--no-daemon']) + +function optionName(token: string): string { + if (token.startsWith('--')) { + return token.split('=', 1)[0] + } + if (/^-[acmspiC].+/.test(token)) { + return token.slice(0, 2) + } + return token +} + +function inlineValue(token: string, option: string): string | undefined { + if (token === option) { + return undefined + } + return token.startsWith('--') ? token.slice(option.length + 1) : token.slice(2) +} + +function isPermissionConfig(value: string): boolean { + const key = parseTomlKeyPath(value)?.segments[0] + return ( + key === 'approval_policy' || + key === 'sandbox_mode' || + key === 'approvals_reviewer' || + key === 'sandbox_workspace_write' + ) +} + +/** Turns saved terminal Arguments into root options for a structured app-server launch. */ +export function codexStructuredLaunchArgs(tokens: readonly string[]): string[] { + const args: string[] = [] + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (token === '--') { + break + } + const option = optionName(token) + if (option === '--remote' || option === '--remote-auth-token-env') { + throw new Error(`Codex structured chat cannot use ${option} in Arguments`) + } + if (DROPPED_SWITCHES.has(option)) { + continue + } + if (PASSTHROUGH_SWITCHES.has(option) && token === option) { + args.push(token) + continue + } + if (VALUE_OPTIONS.has(option) || DROPPED_VALUE_OPTIONS.has(option)) { + const value = inlineValue(token, option) ?? tokens[++index] + if (!value || value === '--' || (value.startsWith('-') && !token.includes('='))) { + throw new Error(`Codex Arguments option ${option} needs a value`) + } + if ( + DROPPED_VALUE_OPTIONS.has(option) || + (['-c', '--config'].includes(option) && isPermissionConfig(value)) + ) { + continue + } + args.push(token) + if (token === option) { + args.push(value) + } + continue + } + if (!token.startsWith('-')) { + throw new Error('Codex structured chat cannot use a positional prompt in Arguments') + } + throw new Error(`Codex structured chat cannot use ${token} in Arguments`) + } + return args +} diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 1e6a6b4805e5..482a4349c576 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -146,8 +146,7 @@ describe('codex structured launch resolution', () => { expect(fresh).not.toHaveProperty('supersedeIfUnsaved') }) - // Agent Permissions is the only thing derived from the arguments field. app-server owns it on - // the thread RPC rather than through the interactive CLI's process flags. + // app-server owns the permission posture on the thread RPC, not process flags. it('resolves the bypass posture as app-server thread policy', async () => { const launch = await resolverFor(record(), undefined, undefined, { codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol' @@ -192,14 +191,27 @@ describe('codex structured launch resolution', () => { expect(launch.model).toBe('gpt-chosen') }) - // The configured CLI arguments are a terminal concern: a durable record written before they - // stopped being read must not smuggle one back into app-server's argv. - it("ignores the record's durable launch arguments", async () => { + it('uses saved arguments before app-server on a fresh launch', async () => { const launch = await resolverFor( - record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] }) + record({ + launchArgs: [ + '--profile', + 'review', + '-c', + 'model_reasoning_effort=high', + '--model', + 'gpt-5.6-sol' + ] + }) )({ identity: IDENTITY }) - expect(launch.args).toEqual(['app-server']) + expect(launch.args).toEqual([ + '-c', + 'model_reasoning_effort=high', + '--model', + 'gpt-5.6-sol', + 'app-server' + ]) }) it('pins resume to the rollout file that proved the durable thread', async () => { @@ -207,6 +219,7 @@ describe('codex structured launch resolution', () => { const launch = await resolverFor( record({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. + launchArgs: ['--enable', 'unified_exec', '-c', 'model_reasoning_effort=high'], providerHandleChain: [ { handle: codexProviderHandle('thread-current') } ] as AgentSessionRecord['providerHandleChain'] @@ -217,6 +230,13 @@ describe('codex structured launch resolution', () => { expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current') expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl') + expect(launch.args).toEqual([ + '--enable', + 'unified_exec', + '-c', + 'model_reasoning_effort=high', + 'app-server' + ]) }) it('refuses a session pinned to another host rather than starting a second writer here', async () => { diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 19945c9a6c89..2b69bc3cbee4 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,6 +15,7 @@ import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import { codexStructuredLaunchArgs } from './codex-structured-launch-args' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -89,8 +90,7 @@ export function createCodexStructuredLaunchResolver( throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) } const { command, environment } = await resolveCodexStructuredInvocation(deps) - // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal - // concern, and the permission posture they used to smuggle in is derived per acquisition. + const args = codexStructuredLaunchArgs(record.launchArgs ?? []) const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) // A Codex record's chain holds only Codex handles; the record store refuses anything else. @@ -99,7 +99,7 @@ export function createCodexStructuredLaunchResolver( const model = record.options?.model return { command, - args: ['app-server'], + args: [...args, 'app-server'], cwd: await deps.resolveWorkspacePath(location.workspaceId), codexHome: accountHome.path, ...(environment ? { env: { ...environment } as Record } : {}), diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index 19b1fe855943..2ae99a42b45b 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -19,7 +19,6 @@ import { type AgentJournalMessageItem, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' import { createTrackedJournalOpener } from './journal-host-database-test-support' diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts new file mode 100644 index 000000000000..beb2b480ea69 --- /dev/null +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -0,0 +1,92 @@ +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + requiresTuiAgentLaunchCommand, + resolveStructuredAgentCommand +} from './structured-agent-command-resolution' + +const scratch: string[] = [] +afterEach(() => { + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function executable() { + const directory = mkdtempSync(join(tmpdir(), 'orca-command-')) + scratch.push(directory) + const command = join(directory, 'agent custom') + writeFileSync(command, '#!/bin/sh\nexit 0\n') + chmodSync(command, 0o755) + return { directory, command } +} + +describe('structured agent executable resolution', () => { + it.each(['claude', 'codex'] as const)( + 'uses the configured %s executable on the execution host', + (agent) => { + const { command } = executable() + const settings = { agentCmdOverrides: { [agent]: `"${command}"` } } + expect(requiresTuiAgentLaunchCommand(settings, agent)).toBe(false) + expect(resolveStructuredAgentCommand(agent, settings)).toBe(command) + } + ) + + it('resolves a basename through the configured PATH and a home-relative path through the host home', () => { + const { directory, command } = executable() + const basenameCommand = join(directory, 'agent-custom') + writeFileSync(basenameCommand, '#!/bin/sh\nexit 0\n') + chmodSync(basenameCommand, 0o755) + expect( + resolveStructuredAgentCommand('codex', { + agentCmdOverrides: { codex: 'agent-custom' }, + agentDefaultEnv: { codex: { PATH: directory } } + }) + ).toBe(basenameCommand) + expect( + resolveStructuredAgentCommand( + 'claude', + { + agentCmdOverrides: { claude: '"~/agent custom"' } + }, + { homePath: directory } + ) + ).toBe(command) + }) + + it('rereads a changed override instead of storing another copy of the command', () => { + const first = executable() + const second = executable() + const settings = { agentCmdOverrides: { claude: `"${first.command}"` } } + expect(resolveStructuredAgentCommand('claude', settings)).toBe(first.command) + settings.agentCmdOverrides.claude = `"${second.command}"` + expect(resolveStructuredAgentCommand('claude', settings)).toBe(second.command) + }) + + it('keeps missing files, directories, relative paths and shell lines on the terminal route', () => { + const { directory } = executable() + const folder = join(directory, 'folder') + mkdirSync(folder) + for (const command of [ + join(directory, 'missing'), + folder, + './claude', + 'npx claude', + 'wrapper --flag' + ]) { + const settings = { agentCmdOverrides: { claude: command } } + expect(requiresTuiAgentLaunchCommand(settings, 'claude')).toBe(true) + expect(() => resolveStructuredAgentCommand('claude', settings)).toThrow('one executable file') + } + }) + + it.skipIf(process.platform === 'win32')('requires executable permission on Unix', () => { + const { command } = executable() + chmodSync(command, 0o644) + expect( + requiresTuiAgentLaunchCommand({ agentCmdOverrides: { claude: `"${command}"` } }, 'claude') + ).toBe(true) + }) +}) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts new file mode 100644 index 000000000000..d2dae02d59e5 --- /dev/null +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -0,0 +1,55 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { TuiAgent } from '../../shared/tui-agent' +import { + resolveCliCommand, + resolveExecutableCommand +} from '../../shared/node-cli-command-resolution' +import { + hasExplicitTuiLaunchCommand, + structuredAgentCommandToken +} from '../../shared/tui-agent-launch-command-override' + +type CommandSettings = Partial> +type CommandOptions = NonNullable[1]> + +function resolveOverride( + agent: TuiAgent, + settings: CommandSettings | null | undefined, + options: CommandOptions +) { + const token = structuredAgentCommandToken(settings?.agentCmdOverrides?.[agent] ?? '') + const overlay = settings?.agentDefaultEnv?.[agent] + return token + ? resolveExecutableCommand(token, { + ...options, + pathEnv: options.pathEnv ?? overlay?.PATH ?? overlay?.Path + }) + : null +} + +/** The host verifies executable overrides before offering either structured launch surface. */ +export function requiresTuiAgentLaunchCommand( + settings: CommandSettings | null | undefined, + agent: TuiAgent +): boolean { + return ( + hasExplicitTuiLaunchCommand(settings, agent) || + Boolean(settings?.agentCmdOverrides?.[agent]?.trim() && !resolveOverride(agent, settings, {})) + ) +} + +/** Re-read the existing setting for every session acquisition and catalog probe. */ +export function resolveStructuredAgentCommand( + agent: 'claude' | 'codex', + settings: CommandSettings, + options: CommandOptions = {} +): string { + if (!settings.agentCmdOverrides?.[agent]?.trim()) { + return resolveCliCommand(agent, options) + } + const command = resolveOverride(agent, settings, options) + if (!command) { + throw new Error(`${agent} Command must name one executable file for structured chat`) + } + return command +} diff --git a/src/main/native-chat/structured-agent-configured-args.test.ts b/src/main/native-chat/structured-agent-configured-args.test.ts new file mode 100644 index 000000000000..58823a406088 --- /dev/null +++ b/src/main/native-chat/structured-agent-configured-args.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentConfiguredArgs } from './structured-agent-configured-args' + +describe('structured chat configured Arguments', () => { + it.each(['claude', 'codex'] as const)( + 'reads the existing %s setting and preserves quoted values', + (agent) => { + expect( + structuredAgentConfiguredArgs( + agent, + { + agentDefaultArgs: { [agent]: '--model "model with spaces"' } + }, + 'darwin' + ) + ).toEqual(['--model', 'model with spaces']) + expect(structuredAgentConfiguredArgs(agent, { agentDefaultArgs: { [agent]: '' } })).toEqual( + [] + ) + } + ) + + it('uses the existing default when the Arguments key is absent', () => { + expect(structuredAgentConfiguredArgs('claude', {})).toEqual(['--dangerously-skip-permissions']) + }) + + it('preserves Windows paths under the configured shell', () => { + expect( + structuredAgentConfiguredArgs( + 'claude', + { + agentDefaultArgs: { claude: String.raw`--plugin-dir "C:\My Plugins"` }, + terminalWindowsShell: 'powershell' + }, + 'win32' + ) + ).toEqual(['--plugin-dir', String.raw`C:\My Plugins`]) + }) + + it('refuses unclosed quotes without spawning', () => { + expect(() => + structuredAgentConfiguredArgs('claude', { + agentDefaultArgs: { claude: '--model "unfinished' } + }) + ).toThrow('Arguments are invalid') + }) +}) diff --git a/src/main/native-chat/structured-agent-configured-args.ts b/src/main/native-chat/structured-agent-configured-args.ts new file mode 100644 index 000000000000..3b60c7642680 --- /dev/null +++ b/src/main/native-chat/structured-agent-configured-args.ts @@ -0,0 +1,27 @@ +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { resolveTuiAgentLaunchArgs } from '../../shared/tui-agent-launch-defaults' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' + +/** Use the same grouping rules as terminal launches before the provider filters its owned flags. */ +export function structuredAgentConfiguredArgs( + agent: AgentSessionHandleProvider, + settings: Partial>, + platform: NodeJS.Platform = process.platform +): string[] { + const shell = + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) ?? 'posix' + const parsed = tokenizeStartupCommand( + resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + shell + ) + if (!parsed.ok) { + throw new Error(`${agent} Arguments are invalid: ${parsed.error}`) + } + return parsed.tokens +} diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts index c578bdbbe68b..ff8a32437c95 100644 --- a/src/main/native-chat/structured-agent-session-create-support.test.ts +++ b/src/main/native-chat/structured-agent-session-create-support.test.ts @@ -87,12 +87,35 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { expect( support({ agent, - getSettings: () => ({ ...HOST_SELECTED, agentCmdOverrides: { [agent]: 'wrapper' } }) + getSettings: () => ({ ...HOST_SELECTED, agentCmdOverrides: { [agent]: 'wrapper --arg' } }) }) ).toEqual({ supported: false, reason: 'agent' }) } ) + it.each(['claude', 'codex'] as const)( + 'supports %s with a verified executable override', + (agent) => { + expect( + support({ + agent, + getSettings: () => ({ + ...HOST_SELECTED, + agentCmdOverrides: { [agent]: `"${process.execPath}"` } + }) + }) + ).toEqual({ supported: true }) + } + ) + + it('refuses a missing executable on the execution host', () => { + expect( + support({ + getSettings: () => ({ ...HOST_SELECTED, agentCmdOverrides: { claude: '/missing/claude' } }) + }) + ).toEqual({ supported: false, reason: 'agent' }) + }) + it('ignores a blank launch command override', () => { expect( support({ getSettings: () => ({ ...HOST_SELECTED, agentCmdOverrides: { claude: ' ' } }) }) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index a6323db075a1..276143e761a1 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,7 +1,7 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { GlobalSettings } from '../../shared/global-settings-types' -import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' +import { requiresTuiAgentLaunchCommand } from './structured-agent-command-resolution' import { readClaudeManagedAccountGateSettings, structuredClaudeMatchesActiveManagedAccount, @@ -23,7 +23,7 @@ export function resolveStructuredAgentSessionCreateSupport(input: { location: AgentSessionExecutionLocation adapterSupportsCreate: boolean getSettings: () => ClaudeManagedAccountGateSettings & - Partial> + Partial> }): StructuredAgentSessionCreateSupport { if (!input.adapterSupportsCreate) { return { @@ -38,7 +38,7 @@ export function resolveStructuredAgentSessionCreateSupport(input: { } // This host's own launch command override names a process only a terminal runs, whichever // client asked; a client routes on its own override for its own machine only. - if (hasExplicitTuiLaunchCommand(readSettingsOrNull(input.getSettings), input.agent)) { + if (requiresTuiAgentLaunchCommand(readSettingsOrNull(input.getSettings), input.agent)) { return { supported: false, reason: 'agent' } } // Claude only: Codex resolves its account on a different path, so its answer is untouched here. diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4333cc7e3a5e..80adb3063945 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -26,6 +26,8 @@ import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' +import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args' export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { async getWorktreePs( @@ -150,6 +152,12 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, + resolveClaudeCommand: () => + resolveStructuredAgentCommand('claude', this.requireStore().getSettings()), + resolveCodexCommand: (options) => + resolveStructuredAgentCommand('codex', this.requireStore().getSettings(), options), + resolveLaunchArgs: (agent) => + structuredAgentConfiguredArgs(agent, this.requireStore().getSettings()), resolveLaunchEnvOverlay: () => resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeLaunchEnv: () => @@ -159,7 +167,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveClaudeAuthPolicy: () => claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()), // Re-read per acquisition, like the auth policy above it: the Agent Permissions setting is - // the one copy of this fact, and the configured CLI arguments never reach a structured launch. + // the one copy of this fact, even when Arguments contain permission flags. resolveClaudePermissionMode: () => claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()), resolveCodexPermissionPolicy: () => diff --git a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts new file mode 100644 index 000000000000..7bc27a33f0d1 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +const installed = vi.hoisted(() => + vi.fn<(deps: StructuredAgentSessionRuntimeDeps) => Promise>(async () => {}) +) +vi.mock('./structured-agent-session-runtime', () => ({ + ensureStructuredAgentSessionHost: installed +})) +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +import { OrcaRuntimeService } from './orca-runtime' + +describe('execution host structured launch settings wiring', () => { + it('reads saved Command and Arguments from the host settings and rereads changes', async () => { + installed.mockClear() + const settings: Partial = { + agentCmdOverrides: { claude: `"${process.execPath}"`, codex: `"${process.execPath}"` }, + agentDefaultArgs: { claude: '--model "model one"', codex: '-c model_reasoning_effort=high' } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked installer only reads the store's getSettings method. + const runtime = new OrcaRuntimeService({ getSettings: () => settings } as never) + await runtime.ensureStructuredAgentSessionHost() + const deps = installed.mock.calls[0]?.[0] + expect(deps).toBeDefined() + expect(deps?.resolveClaudeCommand?.()).toBe(process.execPath) + expect(deps?.resolveCodexCommand?.()).toBe(process.execPath) + expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'model one']) + expect(await deps?.resolveLaunchArgs?.('codex')).toEqual(['-c', 'model_reasoning_effort=high']) + settings.agentDefaultArgs = { claude: '--model second', codex: '' } + expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'second']) + expect(await deps?.resolveLaunchArgs?.('codex')).toEqual([]) + settings.agentCmdOverrides = { claude: 'wrapper --arg' } + expect(() => deps?.resolveClaudeCommand?.()).toThrow('one executable file') + }) +}) diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index b78a9c127408..c942fb97c54c 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -250,7 +250,7 @@ describe('buildAgentLaunchRouteInput', () => { it.each([ ['a cwd', { cwd: '/repo/sub' }, {}], - ['a settings command override', {}, { agentCmdOverrides: { codex: 'codex-nightly' } }] + ['a shell command override', {}, { agentCmdOverrides: { codex: 'npx codex' } }] ] as const)('requires a terminal for %s', (_name, tuiCustomization, settingsOverride) => { const input = buildAgentLaunchRouteInput( store({ ...STRUCTURED_SETTINGS, ...settingsOverride }), @@ -263,6 +263,17 @@ describe('buildAgentLaunchRouteInput', () => { expect(input.requiresTuiLaunchCommand).toBe(true) }) + it.each(['claude', 'codex'] as const)( + 'lets the host verify a single %s executable override', + (agent) => { + const input = buildAgentLaunchRouteInput( + store({ ...STRUCTURED_SETTINGS, agentCmdOverrides: { [agent]: 'agent-nightly' } }), + { agent, workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } } + ) + expect(input.requiresTuiLaunchCommand).toBe(false) + } + ) + // The reported P0: `--dangerously-skip-permissions --model Opus` matched no blessed string, so // every new Claude tab was silently demoted to the terminal-backed chat. The Arguments field is // a terminal concern and no longer reaches this decision. diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index 2c5fabde89d5..9c9f1aeaf282 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -187,7 +187,7 @@ describe('resolveAgentLaunchRoute', () => { false ) expect( - hasExplicitTuiLaunchCommand({ agentCmdOverrides: { codex: 'codex-nightly' } }, 'codex') + hasExplicitTuiLaunchCommand({ agentCmdOverrides: { codex: 'npx codex' } }, 'codex') ).toBe(true) }) }) diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index bffb93c1b9a2..3bb7dfec0677 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -69,10 +69,8 @@ export type AgentLaunchIntent = { * silently inherit whatever the settings happen to hold. * * Deliberately NOT a route input. `hasExplicitTuiLaunchCommand` reads the launch *command* and - * pointedly not the arguments, because structured chat drives Claude through the Agent SDK and - * Codex through app-server, whose option sets are versioned independently of the interactive - * CLI's. So args reaching a structured launch are ignored rather than forcing a terminal — the - * host says so in `warning` instead of quietly honouring neither the args nor the preference. + * not this per-call override. Structured chat reads the execution host's saved Arguments; + * per-call overrides remain terminal-only and the host reports that in `warning`. */ agentArgs?: string | null /** diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index c7407ac5eb13..040894bfae85 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -278,6 +278,27 @@ export function resolveCliCommand( return installCandidate ?? commandName } +/** Resolve a literal override without falling back to an unverified command name. */ +export function resolveExecutableCommand( + command: string, + options: ResolveCommandOptions = {} +): string | null { + const platform = options.platform ?? process.platform + const expanded = + command.startsWith('~/') || command.startsWith('~\\') + ? join(options.homePath ?? homedir(), command.slice(2)) + : command + if (isAbsolute(expanded)) { + return isRunnableCommand(platform, expanded) ? expanded : null + } + // Relative paths depend on the workspace, which a session-less catalog cannot name. + if (expanded.includes('/') || expanded.includes('\\')) { + return null + } + const resolved = resolveCliCommand(expanded, options) + return isAbsolute(resolved) && isRunnableCommand(platform, resolved) ? resolved : null +} + export function resolveCliCommands( commandNames: readonly string[], options: ResolveCommandOptions = {} diff --git a/src/shared/tui-agent-launch-command-override.test.ts b/src/shared/tui-agent-launch-command-override.test.ts new file mode 100644 index 000000000000..cd5ab39ac973 --- /dev/null +++ b/src/shared/tui-agent-launch-command-override.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + hasExplicitTuiLaunchCommand, + structuredAgentCommandToken +} from './tui-agent-launch-command-override' + +describe('structured agent command routing', () => { + it.each([ + ['claude-nightly', 'claude-nightly'], + ['/opt/tools/claude', '/opt/tools/claude'], + ['"/opt/my tools/claude"', '/opt/my tools/claude'], + [String.raw`/opt/my\ tools/claude`, '/opt/my tools/claude'], + [ + String.raw`"C:\Program Files\Claude\claude.exe"`, + String.raw`C:\Program Files\Claude\claude.exe` + ], + ['~/bin/codex', '~/bin/codex'] + ])('accepts one literal executable %s', (input, token) => { + expect(structuredAgentCommandToken(input)).toBe(token) + for (const agent of ['claude', 'codex'] as const) { + expect(hasExplicitTuiLaunchCommand({ agentCmdOverrides: { [agent]: input } }, agent)).toBe( + false + ) + } + }) + + it.each([ + 'npx claude', + 'wrapper --flag', + 'claude;codex', + '$CLI', + '"$HOME/bin/claude"', + '`which claude`', + 'claude | cat', + 'claude\ncodex', + '"unfinished' + ])('keeps shell syntax %s in terminal chat', (command) => { + expect(structuredAgentCommandToken(command)).toBeNull() + expect(hasExplicitTuiLaunchCommand({ agentCmdOverrides: { claude: command } }, 'claude')).toBe( + true + ) + }) + + it('leaves other agents and empty overrides unchanged', () => { + expect(hasExplicitTuiLaunchCommand({ agentCmdOverrides: { kimi: '/bin/kimi' } }, 'kimi')).toBe( + true + ) + expect(hasExplicitTuiLaunchCommand({ agentCmdOverrides: { claude: ' ' } }, 'claude')).toBe( + false + ) + expect(hasExplicitTuiLaunchCommand(null, 'codex')).toBe(false) + }) +}) diff --git a/src/shared/tui-agent-launch-command-override.ts b/src/shared/tui-agent-launch-command-override.ts index 0dea2c02754e..23562eb3e758 100644 --- a/src/shared/tui-agent-launch-command-override.ts +++ b/src/shared/tui-agent-launch-command-override.ts @@ -1,21 +1,33 @@ import type { GlobalSettings } from './global-settings-types' import type { TuiAgent } from './tui-agent' +import { tokenizeCustomCommandTemplate } from './commit-message-prompt' -/** - * Whether the user replaced this agent's launch command with one only a terminal can run. - * - * Shared rather than renderer-local because both launch surfaces have to answer it: the renderer - * routes such a launch back to the TUI, and orchestration falls a worker back to a PTY so the - * custom command still applies. - * - * Arguments and environment are deliberately not read here. Structured native chat applies the - * configured environment itself, and the Arguments field is a terminal/TUI concern: structured - * chat drives Claude through the Agent SDK and Codex through app-server, whose option sets are - * independently versioned and need not match the interactive CLI's. - */ +/** A single literal executable; the execution host verifies that the file is runnable. */ +export function structuredAgentCommandToken(command: string): string | null { + const input = command.trim() + if (!input || /[\0\r\n$`]/.test(input)) { + return null + } + const windowsPath = /^(?:["']?)(?:[A-Za-z]:[\\/]|\\\\)/.test(input) + const parsed = tokenizeCustomCommandTemplate(input, windowsPath ? 'literal' : 'escape') + if ( + !parsed.ok || + parsed.tokens.length !== 1 || + parsed.spans.some((span) => span.divergesFromShell) + ) { + return null + } + const token = parsed.tokens[0] + return token && !/[|&;<>(){}[\]*?!]/.test(token) ? token : null +} + +/** Shell lines retain terminal chat; executable overrides can use structured chat. */ export function hasExplicitTuiLaunchCommand( settings: Partial> | null | undefined, agent: TuiAgent ): boolean { - return Boolean(settings?.agentCmdOverrides?.[agent]?.trim()) + const command = settings?.agentCmdOverrides?.[agent]?.trim() + return Boolean( + command && ((agent !== 'claude' && agent !== 'codex') || !structuredAgentCommandToken(command)) + ) } From cd4d7c3a4c754560485a43c3e97147834573fa56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:35:26 -0700 Subject: [PATCH 2/9] fix(test): retain provider import after syncing main --- .../agent-session-journal/journal-submission-positions.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index 3e05bbdbbcc1..374163786053 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -18,6 +18,7 @@ import { type AgentJournalMessageItem, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' import { createTrackedJournalOpener } from './journal-host-database-test-support' From 54a1b350b71b1ead40c4c77aef0eb8b1ac420fa0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:40:14 -0700 Subject: [PATCH 3/9] fix(native-chat): preserve launch arguments and explain invalid settings --- pnpm-lock.yaml | 8 +++ .../claude-agent-sdk-contract-pins.test.ts | 11 ++++- .../claude-structured-launch-args.test.ts | 49 +++++++++++++++---- .../claude/claude-structured-launch-args.ts | 34 ++++++++----- ...laude-structured-launch-resolution.test.ts | 42 +++++++++++++++- .../claude-structured-launch-resolution.ts | 13 +++-- .../codex-structured-launch-args.test.ts | 37 ++++++++++---- .../codex/codex-structured-launch-args.ts | 9 ++-- ...codex-structured-launch-resolution.test.ts | 41 +++++++++++++++- .../codex-structured-launch-resolution.ts | 5 +- ...-agent-session-accept-then-deliver.test.ts | 23 +++++++++ .../structured-agent-session-agent-start.ts | 11 ++++- .../structured-agent-session-delivery-loop.ts | 7 ++- ...red-agent-session-failed-create-refusal.ts | 14 ++++-- ...uctured-agent-session-failure-text.test.ts | 36 +++++++++++++- .../structured-agent-session-failure-text.ts | 14 ++++-- ...ent-session-pre-spawn-first-answer.test.ts | 14 ++++++ .../structured-agent-arguments-error.ts | 44 +++++++++++++++++ .../structured-agent-session-runtime.ts | 12 ++--- .../structured-claude-runtime-adapter.ts | 2 + .../agent-session-failure-words-text.test.ts | 20 +++++++- .../agent-session-failure-words-text.ts | 25 ++++++++++ src/renderer/src/i18n/locales/en.json | 7 ++- src/shared/agent-session-failure-copy.ts | 6 +++ .../agent-session-failure-fact-schema.ts | 5 +- src/shared/agent-session-failure-words.ts | 14 ++++++ src/shared/agent-session-failure.test.ts | 26 ++++++++++ src/shared/agent-session-failure.ts | 40 ++++++++++++++- 28 files changed, 506 insertions(+), 63 deletions(-) create mode 100644 src/main/native-chat/structured-agent-arguments-error.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbf12ea1ae17..9f833c400ca9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: '@stablyai/playwright-test': specifier: ^2.1.16 version: 2.1.16(@playwright/test@1.63.0)(zod@4.6.5) + '@streamparser/json': + specifier: 0.0.26 + version: 0.0.26 '@tailwindcss/vite': specifier: ^4.2.4 version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) @@ -3188,6 +3191,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@streamparser/json@0.0.26': + resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==} + '@swc/core-darwin-arm64@1.15.46': resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} @@ -10214,6 +10220,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@streamparser/json@0.0.26': {} + '@swc/core-darwin-arm64@1.15.46': optional: true diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index 3a8e4ea494e2..f6f588a07f59 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -348,7 +348,11 @@ describe('Claude Agent SDK contract pins', () => { '--dangerously-skip-permissions', '--output-format', 'text', - '--session-id=wrong-session' + '--session-id=wrong-session', + '--add-dir', + '/repo/one', + '/repo/two', + '--add-dir=/repo/three' ]) await drainQuery({ ...launch.options, @@ -367,6 +371,11 @@ describe('Claude Agent SDK contract pins', () => { expect(argv).not.toContain('--allow-dangerously-skip-permissions') expect(argv[argv.indexOf('--permission-mode') + 1]).toBe('default') expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5') + expect(argv.flatMap((arg, index) => (arg === '--add-dir' ? [argv[index + 1]] : []))).toEqual([ + '/repo/one', + '/repo/two', + '/repo/three' + ]) expect(argv.filter((arg) => arg === '--model')).toHaveLength(1) expect(argv.filter((arg) => arg === '--output-format')).toHaveLength(1) expect(argv[argv.indexOf('--output-format') + 1]).toBe('stream-json') diff --git a/src/main/claude/claude-structured-launch-args.test.ts b/src/main/claude/claude-structured-launch-args.test.ts index d6b6049a4dc3..6a788b836c96 100644 --- a/src/main/claude/claude-structured-launch-args.test.ts +++ b/src/main/claude/claude-structured-launch-args.test.ts @@ -1,25 +1,26 @@ import { describe, expect, it } from 'vitest' -import { claudeStructuredExtraArgs } from './claude-structured-launch-args' +import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' describe('Claude structured launch arguments', () => { it('translates long flags, equals values, and the model short flag', () => { expect( - claudeStructuredExtraArgs([ - '--model', + claudeStructuredLaunchArgs([ + '-m', 'opus', '--effort=high', - '-m', - 'sonnet', '--chrome', '--add-dir', '/repo/other' ]) - ).toEqual({ model: 'sonnet', effort: 'high', chrome: null, 'add-dir': '/repo/other' }) + ).toEqual({ + extraArgs: { model: 'opus', effort: 'high', chrome: null }, + additionalDirectories: ['/repo/other'] + }) }) it('removes SDK transport, permission, and lifecycle flags with their values', () => { expect( - claudeStructuredExtraArgs([ + claudeStructuredLaunchArgs([ '-p', '--input-format', 'text', @@ -48,14 +49,42 @@ describe('Claude structured launch arguments', () => { '--model', 'opus' ]) - ).toEqual({ model: 'opus' }) + ).toEqual({ extraArgs: { model: 'opus' }, additionalDirectories: [] }) }) it('ignores positional prompts and tokens after --', () => { expect( - claudeStructuredExtraArgs(['prompt', '--model', 'opus', '--', '--effort', 'high']) + claudeStructuredLaunchArgs(['prompt', '--model', 'opus', '--', '--effort', 'high']) ).toEqual({ - model: 'opus' + extraArgs: { model: 'opus' }, + additionalDirectories: [] }) }) + it('keeps repeated and multi-value directory options in order', () => { + expect( + claudeStructuredLaunchArgs([ + '--add-dir', + '/one', + '/two', + '--model', + 'opus', + '--add-dir=/three', + '/four' + ]) + ).toEqual({ + extraArgs: { model: 'opus' }, + additionalDirectories: ['/one', '/two', '/three', '/four'] + }) + }) + + it.each([ + ['--model', 'opus', '-m', 'sonnet'], + ['--model', 'opus', 'sonnet'], + ['--effort=high', 'medium'], + ['--chrome', '--chrome'], + ['--add-dir'], + ['--add-dir='] + ])('refuses options the SDK cannot preserve: %j', (...args) => { + expect(() => claudeStructuredLaunchArgs(args)).toThrow(/Arguments/) + }) }) diff --git a/src/main/claude/claude-structured-launch-args.ts b/src/main/claude/claude-structured-launch-args.ts index 780e716e0f96..1a4ac11b1da7 100644 --- a/src/main/claude/claude-structured-launch-args.ts +++ b/src/main/claude/claude-structured-launch-args.ts @@ -1,3 +1,5 @@ +import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error' + /** Flags supplied by the SDK or owned by Orca's structured transport. */ const OWNED_FLAGS = new Set([ 'print', @@ -40,9 +42,13 @@ const SHORT_FLAGS: Record = { '-v': 'version' } -/** Translate a pinned CLI argv into the SDK's `--key [value]` extraArgs shape. */ -export function claudeStructuredExtraArgs(args: readonly string[]): Record { +/** Keep variadic directory options in the SDK array; scalar extraArgs cannot retain repetitions. */ +export function claudeStructuredLaunchArgs(args: readonly string[]): { + extraArgs: Record + additionalDirectories: string[] +} { const extraArgs: Record = {} + const additionalDirectories: string[] = [] for (let index = 0; index < args.length; index++) { const arg = args[index]! if (arg === '--') { @@ -56,18 +62,24 @@ export function claudeStructuredExtraArgs(args: readonly string[]): Record !value)) { + throw new StructuredAgentArgumentsError('Claude', '--add-dir', 'missingValue') + } + additionalDirectories.push(...values) + continue + } + if (Object.hasOwn(extraArgs, flag) || values.length > 1) { + throw new StructuredAgentArgumentsError('Claude', `--${flag}`, 'multipleValues') + } + extraArgs[flag] = values[0] ?? null } - return extraArgs + return { extraArgs, additionalDirectories } } diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index a3489688c6a3..b34c0e30a232 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -60,7 +60,8 @@ function resolverFor( stripAuthEnv = false, // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, - hasTranscript: () => Promise = async () => true + hasTranscript: () => Promise = async () => true, + resolveLaunchArgs?: () => string[] ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, @@ -69,6 +70,7 @@ function resolverFor( resolveAuthPolicy: () => ({ stripAuthEnv }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, + ...(resolveLaunchArgs ? { resolveLaunchArgs } : {}), ...(resolveEnv ? { resolveEnv } : {}) }) } @@ -312,12 +314,48 @@ describe('claude structured launch resolution', () => { expect(launch.options.sessionId).toBe(launch.providerSessionId) }) + it('re-reads saved Arguments after a refusal and on resume instead of using a stale record', async () => { + let args = ['--model', 'one', '--model', 'two'] + const resolve = resolverFor( + record({ ...RESUMABLE, launchArgs: ['--model', 'stale'] }), + undefined, + false, + { claude: '' }, + async () => true, + () => args + ) + await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toThrow(/Arguments/) + args = ['--effort', 'high'] + expect((await resolve({ identity: identityAt('leaf-current') })).options.extraArgs).toEqual({ + effort: 'high', + 'replay-user-messages': null + }) + args = [] + expect((await resolve({ identity: identityAt('leaf-current') })).options.extraArgs).toEqual({ + 'replay-user-messages': null + }) + }) + it('passes configured arguments when resuming a transcript', async () => { const launch = await resolverFor( - record({ ...RESUMABLE, launchArgs: ['--effort', 'high', '-r', 'wrong-session'] }) + record({ + ...RESUMABLE, + launchArgs: [ + '--effort', + 'high', + '-r', + 'wrong-session', + '--add-dir', + '/one', + '/two', + '--add-dir', + '/three' + ] + }) )({ identity: identityAt('leaf-current') }) expect(launch.options.resume).toBe('provider-current') + expect(launch.options.additionalDirectories).toEqual(['/one', '/two', '/three']) expect(launch.options.extraArgs).toEqual({ effort: 'high', 'replay-user-messages': null diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 4ddaf941e50e..7944e82d05d1 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -33,7 +33,7 @@ import { import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' -import { claudeStructuredExtraArgs } from './claude-structured-launch-args' +import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const @@ -46,6 +46,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'settingSources' | 'supportedDialogKinds' | 'extraArgs' + | 'additionalDirectories' | 'model' | 'effort' | 'permissionMode' @@ -112,6 +113,7 @@ export type ClaudeStructuredLaunch = { export type ClaudeStructuredLaunchResolverDeps = { store: AgentSessionRecordStore + resolveLaunchArgs?: () => Promise | string[] resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string resolveEnv?: () => @@ -288,7 +290,9 @@ export function createClaudeStructuredLaunchResolver( providerSessionId, claudeConfigDir: record.accountHome.path }))) - const configuredArgs = claudeStructuredExtraArgs(record.launchArgs ?? []) + const configuredArgs = claudeStructuredLaunchArgs( + (await deps.resolveLaunchArgs?.()) ?? record.launchArgs ?? [] + ) const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) @@ -305,8 +309,11 @@ export function createClaudeStructuredLaunchResolver( options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, + ...(configuredArgs.additionalDirectories.length + ? { additionalDirectories: configuredArgs.additionalDirectories } + : {}), extraArgs: { - ...configuredArgs, + ...configuredArgs.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs }, diff --git a/src/main/codex/codex-structured-launch-args.test.ts b/src/main/codex/codex-structured-launch-args.test.ts index 5e6e1e9c8f10..0b156acdd703 100644 --- a/src/main/codex/codex-structured-launch-args.test.ts +++ b/src/main/codex/codex-structured-launch-args.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { codexStructuredLaunchArgs } from './codex-structured-launch-args' +import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error' describe('codexStructuredLaunchArgs', () => { it('preserves root config and feature option order', () => { @@ -71,14 +72,32 @@ describe('codexStructuredLaunchArgs', () => { }) it.each([ - { tokens: ['a prompt'] }, - { tokens: ['app-server'] }, - { tokens: ['--remote', 'wss://host'] }, - { tokens: ['--remote=wss://host'] }, - { tokens: ['--remote-auth-token-env', 'TOKEN'] }, - { tokens: ['--unknown-flag'] }, - { tokens: ['--enable'] } - ])('rejects unsafe or incomplete arguments: %j', ({ tokens }) => { - expect(() => codexStructuredLaunchArgs(tokens)).toThrow() + { tokens: ['a private prompt'], option: 'prompt', problem: 'positionalPrompt' }, + { tokens: ['app-server'], option: 'prompt', problem: 'positionalPrompt' }, + { + tokens: ['--remote', 'wss://private-host'], + option: '--remote', + problem: 'unsupportedOption' + }, + { tokens: ['--remote=wss://private-host'], option: '--remote', problem: 'unsupportedOption' }, + { + tokens: ['--remote-auth-token-env', 'PRIVATE_TOKEN'], + option: '--remote-auth-token-env', + problem: 'unsupportedOption' + }, + { tokens: ['--unknown-flag=secret'], option: '--unknown-flag', problem: 'unsupportedOption' }, + { tokens: ['--enable'], option: '--enable', problem: 'missingValue' } + ] as const)('rejects unsafe or incomplete arguments: %j', ({ tokens, option, problem }) => { + const thrown = () => codexStructuredLaunchArgs(tokens) + expect(thrown).toThrow(StructuredAgentArgumentsError) + try { + thrown() + } catch (error) { + expect(error).toMatchObject({ + argumentProblem: { agent: 'Codex', option, problem } + }) + expect(JSON.stringify(error)).not.toContain('secret') + expect(JSON.stringify(error)).not.toContain('private') + } }) }) diff --git a/src/main/codex/codex-structured-launch-args.ts b/src/main/codex/codex-structured-launch-args.ts index ff3b20d4685a..90b39fffec81 100644 --- a/src/main/codex/codex-structured-launch-args.ts +++ b/src/main/codex/codex-structured-launch-args.ts @@ -1,6 +1,7 @@ // Only root CLI options can precede `app-server`. A prompt or another subcommand // would change the process Orca starts, and permission options belong to Orca. import { parseTomlKeyPath } from './config-toml-key-path' +import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error' const VALUE_OPTIONS = new Set([ '-c', @@ -79,7 +80,7 @@ export function codexStructuredLaunchArgs(tokens: readonly string[]): string[] { } const option = optionName(token) if (option === '--remote' || option === '--remote-auth-token-env') { - throw new Error(`Codex structured chat cannot use ${option} in Arguments`) + throw new StructuredAgentArgumentsError('Codex', option, 'unsupportedOption') } if (DROPPED_SWITCHES.has(option)) { continue @@ -91,7 +92,7 @@ export function codexStructuredLaunchArgs(tokens: readonly string[]): string[] { if (VALUE_OPTIONS.has(option) || DROPPED_VALUE_OPTIONS.has(option)) { const value = inlineValue(token, option) ?? tokens[++index] if (!value || value === '--' || (value.startsWith('-') && !token.includes('='))) { - throw new Error(`Codex Arguments option ${option} needs a value`) + throw new StructuredAgentArgumentsError('Codex', option, 'missingValue') } if ( DROPPED_VALUE_OPTIONS.has(option) || @@ -106,9 +107,9 @@ export function codexStructuredLaunchArgs(tokens: readonly string[]): string[] { continue } if (!token.startsWith('-')) { - throw new Error('Codex structured chat cannot use a positional prompt in Arguments') + throw new StructuredAgentArgumentsError('Codex', token, 'positionalPrompt') } - throw new Error(`Codex structured chat cannot use ${token} in Arguments`) + throw new StructuredAgentArgumentsError('Codex', token, 'unsupportedOption') } return args } diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 482a4349c576..33fc26f77a44 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -42,19 +42,58 @@ function resolverFor( value: AgentSessionRecord | null, resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, resolveRollout: () => Promise = async () => null, - agentDefaultArgs: Record = { codex: '' } + agentDefaultArgs: Record = { codex: '' }, + resolveLaunchArgs?: () => string[] ) { return createCodexStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, + ...(resolveLaunchArgs ? { resolveLaunchArgs } : {}), isWindowsProcessStartTimeAvailable: () => true, resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs }) }) } describe('codex structured launch resolution', () => { + it.each(['start', 'resume'] as const)( + 're-reads saved Arguments after a refusal on %s', + async (mode) => { + let args = ['--remote', 'wss://host'] + const resolve = resolverFor( + record({ + launchArgs: ['--enable', 'stale'], + providerHandleChain: + mode === 'resume' + ? [ + { + linkId: 'link-current', + handle: codexProviderHandle('thread-current'), + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ] + : [] + }), + undefined, + undefined, + { codex: '' }, + () => args + ) + await expect(resolve({ identity: IDENTITY })).rejects.toThrow(/Arguments/) + args = ['--enable', 'unified_exec'] + expect((await resolve({ identity: IDENTITY })).args).toEqual([ + '--enable', + 'unified_exec', + 'app-server' + ]) + args = [] + expect((await resolve({ identity: IDENTITY })).args).toEqual(['app-server']) + } + ) + it('launches the app server in the workspace and account home the record pinned', async () => { const launch = await resolverFor(record())({ identity: IDENTITY }) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 2b69bc3cbee4..a3ceeb12e12c 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -19,6 +19,7 @@ import { codexStructuredLaunchArgs } from './codex-structured-launch-args' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore + resolveLaunchArgs?: () => Promise | string[] /** Absolute path of a workspace on this host. Rejects when the workspace no * longer resolves, which is the case a stale mobile client hits. */ resolveWorkspacePath: (workspaceId: string) => Promise @@ -90,7 +91,9 @@ export function createCodexStructuredLaunchResolver( throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) } const { command, environment } = await resolveCodexStructuredInvocation(deps) - const args = codexStructuredLaunchArgs(record.launchArgs ?? []) + const args = codexStructuredLaunchArgs( + (await deps.resolveLaunchArgs?.()) ?? record.launchArgs ?? [] + ) const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) // A Codex record's chain holds only Codex handles; the record store refuses anything else. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 3f1b4046f542..8a0e71d01207 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -47,6 +47,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' const CALLER = { callerKey: 'client-1' } @@ -322,6 +323,28 @@ describe('a send is answered at acceptance', () => { }) describe('a start the chat needed and did not get', () => { + it('shows a saved Arguments refusal on the resumed chat row and rejected message', async () => { + await host.close(SESSION, 'evict') + acquire.mockRejectedValueOnce( + new AgentSessionPreSpawnError( + new StructuredAgentArgumentsError('Codex', '--unsafe=private', 'unsupportedOption') + ) + ) + const id = await accept('first') + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + const sentence = + "Codex couldn't restart. Saved Arguments contain an unsupported option (--unsafe). Edit them in Settings > Agents > Arguments. Send your message to try again." + expect(await errorRows()).toEqual([sentence]) + expect(await submission(id)).toMatchObject({ + reason: sentence, + rejection: { + kind: 'restartFailed', + argumentProblem: { agent: 'Codex', option: '--unsafe', problem: 'unsupportedOption' } + } + }) + expect(JSON.stringify(await host.journalSnapshot(SESSION))).not.toContain('private') + }) + it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index ec1289d401dd..cacd3f59bf31 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -7,8 +7,10 @@ import { providerDiagnosticOf, + type AgentSessionArgumentProblem, type ProviderDiagnostic } from '../../../shared/agent-session-failure' +import { argumentProblemOf } from '../structured-agent-arguments-error' import { agentSessionRefusalFromReference, readAgentSessionRefusalReference, @@ -48,6 +50,7 @@ export type StructuredAgentSessionResumeOutcome = /** What the provider said about the failed start, for the chat's own record; host-side * only, never on the refusal. */ diagnostic?: ProviderDiagnostic + argumentProblem?: AgentSessionArgumentProblem } /** The attach's caller key: the ledger row a start settles is Orca's own. */ @@ -193,7 +196,13 @@ function withDiagnostic( error: unknown ): StructuredAgentSessionResumeOutcome { const diagnostic = providerDiagnosticOf(error) - return { ok: false, refusal, ...(diagnostic ? { diagnostic } : {}) } + const argumentProblem = argumentProblemOf(error) + return { + ok: false, + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(argumentProblem ? { argumentProblem } : {}) + } } function settledResumeRefusal( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index b9601ed1a8de..9be81f3c3096 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -252,7 +252,11 @@ export class StructuredAgentSessionDeliveryLoop { /** A start the session refused, as the failure every queued message it was for is rejected with. */ private refusedStart( sessionId: string, - { refusal, diagnostic }: Extract + { + refusal, + diagnostic, + argumentProblem + }: Extract ): StartFailure { // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 @@ -261,6 +265,7 @@ export class StructuredAgentSessionDeliveryLoop { cause: { refusal, ...(diagnostic ? { diagnostic } : {}), + ...(argumentProblem ? { argumentProblem } : {}), ...(newSession ? { newSession: true as const } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index 90f52db3d095..a59482c9c6bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -28,6 +28,7 @@ import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import { argumentProblemOf } from '../structured-agent-arguments-error' /** Who a failed acquisition's sentence names, and whether it was the session's first start. */ export type FailedAcquisitionWording = { @@ -39,12 +40,17 @@ export type FailedAcquisitionWording = { * error text is Orca's or the provider's log wording, so it goes to the log, never the wire. */ function failedAcquisitionWireRefusal( details: AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined, - wording: FailedAcquisitionWording + wording: FailedAcquisitionWording, + error: unknown ): AgentSessionWireRefusal { const code = 'agent_session_operation_invalid' const refusal = details ? refuse(code, details, code) : refuseUnclassified(code, code) const { reason } = structuredAgentSessionStartFailure( - { refusal, ...(wording.newSession ? { newSession: true as const } : {}) }, + { + refusal, + argumentProblem: argumentProblemOf(error), + ...(wording.newSession ? { newSession: true as const } : {}) + }, structuredAgentSessionFailureWordsContext(wording.record) ) return { ...refusal, message: reason } @@ -78,7 +84,7 @@ export function failedAcquisitionSettlement( // A store refusal's message is its code, which its replay has always carried. (isAgentSessionWireRefusalCode(raw) ? raw - : failedAcquisitionWireRefusal(details, wording).message) + : failedAcquisitionWireRefusal(details, wording, error).message) return { exitProof, outcome: { @@ -126,7 +132,7 @@ export function failedAcquisitionRefusal( if (error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error)) { return { ok: false, - refusal: failedAcquisitionWireRefusal(failedAcquisitionDetails(error), wording) + refusal: failedAcquisitionWireRefusal(failedAcquisitionDetails(error), wording, error) } } return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index ccef42f57bdd..b508cc9e7571 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -9,8 +9,12 @@ import { refuse, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' -import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError +} from './structured-agent-session-adapter' import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' +import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' import { structuredAgentSessionStartFailure, withObservedProviderExit @@ -21,6 +25,36 @@ const ORCA_INTERNAL = /agent_session_|execution_owner|provider_[a-z_]+|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ describe('structuredAgentSessionStartFailure', () => { + it('names a saved Arguments option in start and restart failures without exposing its value', () => { + const error = new StructuredAgentArgumentsError( + 'Codex', + '--unknown=private', + 'unsupportedOption' + ) + const wrapped = new AgentSessionPreSpawnError(error) + const argumentProblem = { + agent: 'Codex', + option: '--unknown', + problem: 'unsupportedOption' + } as const + expect(structuredAgentSessionStartFailure({ error: wrapped }, { agentName: 'Codex' })).toEqual({ + reason: + "Codex couldn't start. Saved Arguments contain an unsupported option (--unknown). Edit them in Settings > Agents > Arguments. Send your message to try again.", + rejection: { kind: 'startFailed', argumentProblem } + }) + const refusal = refuseUnclassified('agent_session_operation_invalid', 'generic start failure') + expect( + structuredAgentSessionStartFailure({ refusal, argumentProblem }, { agentName: 'Codex' }) + ).toEqual({ + reason: + "Codex couldn't restart. Saved Arguments contain an unsupported option (--unknown). Edit them in Settings > Agents > Arguments. Send your message to try again.", + rejection: { + kind: 'restartFailed', + argumentProblem, + refusal: { code: 'agent_session_operation_invalid' } + } + }) + }) it('keeps a provider diagnostic only when the error carried one', () => { const carried = withProviderDiagnostic( new Error('claude stream-json exited (code 1): boom'), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index c0c2172230dc..45a95a17a75e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -4,6 +4,7 @@ import { agentSessionFailureFact, providerDiagnosticOf, + type AgentSessionArgumentProblem, type SubmissionRejectionFact, type AgentSessionFailureKind, type ProviderDiagnostic @@ -19,6 +20,7 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { argumentProblemOf } from '../structured-agent-arguments-error' /** Start refusals whose situation is itself what the person reads, with its own next step. */ const TYPED_START_REFUSALS = [ @@ -71,7 +73,7 @@ export function providerStartupFailureFact(cause?: unknown): SubmissionRejection } return agentSessionFailureFact( providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', - { detail: providerDiagnosticOf(cause) } + { detail: providerDiagnosticOf(cause), argumentProblem: argumentProblemOf(cause) } ) } @@ -92,7 +94,7 @@ function startupFailureFromExit( function refusedStartFailureFact( cause: Extract ): SubmissionRejectionFact { - const { refusal, diagnostic } = cause + const { refusal, diagnostic, argumentProblem } = cause const reason = refusal.details?.reason const typed = typedStartRefusal(reason) if (typed) { @@ -103,6 +105,7 @@ function refusedStartFailureFact( } return agentSessionFailureFact(cause.newSession ? 'startFailed' : 'restartFailed', { detail: diagnostic, + argumentProblem, refusal: agentSessionRefusalReference(refusal) }) } @@ -111,7 +114,12 @@ function refusedStartFailureFact( export type StructuredAgentSessionStartFailureCause = /** The session could not be made ready; the provider's words, if any, are kept host-side, off * the refusal. `newSession`: one that never ran, so it failed to start rather than restart. */ - | { refusal: AgentSessionWireRefusal; diagnostic?: ProviderDiagnostic; newSession?: true } + | { + refusal: AgentSessionWireRefusal + diagnostic?: ProviderDiagnostic + argumentProblem?: AgentSessionArgumentProblem + newSession?: true + } /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ | { error: unknown } /** The child ended before it proved its start, as its ended event told it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 1e171e64199e..7bc5de6639cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -18,6 +18,7 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -109,6 +110,19 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { } describe('a create that fails before any process spawns', () => { + it('answers a safe saved Arguments problem on the first call and replay', async () => { + const { first, replay } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError( + new StructuredAgentArgumentsError('Claude', '--model=private', 'multipleValues') + ) + ) + const sentence = + "Claude couldn't start. Saved Arguments give --model more than one value. Edit them in Settings > Agents > Arguments. Send your message to try again." + expect(first).toMatchObject({ ok: false, error: { message: sentence } }) + expect(replay).toMatchObject({ ok: false, refusal: { message: sentence } }) + expect(JSON.stringify([first, replay])).not.toContain('private') + }) + it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ [ 'the managed account env override', diff --git a/src/main/native-chat/structured-agent-arguments-error.ts b/src/main/native-chat/structured-agent-arguments-error.ts new file mode 100644 index 000000000000..f6fca945307d --- /dev/null +++ b/src/main/native-chat/structured-agent-arguments-error.ts @@ -0,0 +1,44 @@ +import type { AgentSessionArgumentProblem } from '../../shared/agent-session-failure' + +/** A saved Arguments refusal with only the option name, never its value. */ +export class StructuredAgentArgumentsError extends Error { + readonly argumentProblem: AgentSessionArgumentProblem + + constructor( + agent: AgentSessionArgumentProblem['agent'], + option: string, + problem: AgentSessionArgumentProblem['problem'] + ) { + const normalizedOption = + problem === 'positionalPrompt' + ? 'prompt' + : (option.match(/^--[a-zA-Z][a-zA-Z0-9-]{0,63}/)?.[0] ?? + option.match(/^-[a-zA-Z]/)?.[0] ?? + '--?') + super(`${agent} structured chat cannot use ${normalizedOption} in saved Arguments`) + this.name = 'StructuredAgentArgumentsError' + this.argumentProblem = { agent, option: normalizedOption, problem } + } +} + +/** Finds a typed Arguments refusal through acquisition wrappers. */ +export function argumentProblemOf( + error: unknown, + depth = 0 +): AgentSessionArgumentProblem | undefined { + if (depth >= 6 || !(error instanceof Error)) { + return undefined + } + if (error instanceof StructuredAgentArgumentsError) { + return error.argumentProblem + } + if (error instanceof AggregateError) { + for (const inner of error.errors) { + const problem = argumentProblemOf(inner, depth + 1) + if (problem) { + return problem + } + } + } + return argumentProblemOf(error.cause, depth + 1) +} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b5efd11efad1..3c19f4a4833b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -258,6 +258,9 @@ async function installOnJournal( store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: resolveCodexEnvironment, + ...(deps.resolveLaunchArgs + ? { resolveLaunchArgs: () => deps.resolveLaunchArgs!('codex') } + : {}), ...(deps.resolveCodexPermissionPolicy ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } : {}), @@ -284,6 +287,9 @@ async function installOnJournal( ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), resolveClaudeInheritedEnv, + ...(deps.resolveLaunchArgs + ? { resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs!('claude') } + : {}), resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } @@ -315,12 +321,6 @@ async function installOnJournal( claimKeyId: deps.claimKeyId, probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), - ...(deps.resolveLaunchArgs - ? { - resolveLaunchArgs: async (provider: AgentSessionRecord['provider']) => - await deps.resolveLaunchArgs!(provider) - } - : {}), logger: deps.logger, ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 8b4b70f8bc2b..2e27d019604b 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -22,6 +22,7 @@ import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise + resolveClaudeLaunchArgs?: () => Promise | string[] resolveClaudeCommand?: () => string resolveClaudeLaunchEnv?: () => Promise> | Record /** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */ @@ -83,6 +84,7 @@ export function createStructuredClaudeRuntimeAdapter( resolveLaunch: createClaudeStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, + ...(deps.resolveClaudeLaunchArgs ? { resolveLaunchArgs: deps.resolveClaudeLaunchArgs } : {}), resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), ...(deps.resolveClaudeInheritedEnv diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 47ce0552c596..143a0dd4e8b1 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -6,6 +6,7 @@ import zh from '@/i18n/locales/zh.json' import { AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS, AGENT_SESSION_FAILURE_KINDS, + readWholeAgentSessionFailureFact, type AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { @@ -45,7 +46,8 @@ const VALUES = { command: 'compact', detail: 'Image type .bmp', limit: '20', - size: '5' + size: '5', + option: '--remote' } function factsFor(kind: AgentSessionFailureFact['kind']): AgentSessionFailureFact[] { @@ -221,6 +223,22 @@ describe('desktop words for a failure fact', () => { ) }) + it('uses the host sentence if the argument detail is newer than this reader', () => { + const sentence = 'Codex could not start. Edit saved Arguments in Settings > Agents.' + expect( + agentSessionWriteNoticeText( + structuredAgentSessionRejectionParts( + sentence, + 'send', + readWholeAgentSessionFailureFact({ + kind: 'startFailed', + argumentProblem: { agent: 'Codex', option: '--remote', problem: 'futureProblem' } + }) + ) + ) + ).toBe(sentence) + }) + it('shows a host sentence with no fact beside it as written', async () => { await i18n.changeLanguage('fr') expect( diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 59c4947c8b4f..360f2567a6dc 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -40,6 +40,31 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.argumentsUnsupportedOption', + COPY.argumentsUnsupportedOption, + values + ), + argumentsMissingValue: (values) => + translate( + 'components.native-chat.failureWords.argumentsMissingValue', + COPY.argumentsMissingValue, + values + ), + argumentsMultipleValues: (values) => + translate( + 'components.native-chat.failureWords.argumentsMultipleValues', + COPY.argumentsMultipleValues, + values + ), + argumentsPositionalPrompt: () => + translate( + 'components.native-chat.failureWords.argumentsPositionalPrompt', + COPY.argumentsPositionalPrompt + ), + editSavedArguments: () => + translate('components.native-chat.failureWords.editSavedArguments', COPY.editSavedArguments), terminalAgentHoldsChat: () => translate( 'components.native-chat.writeNotice.terminalAgentHoldsChat', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 04a6b14dccdb..534941011e85 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17786,7 +17786,12 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "Couldn't stop {{agent}} from before." + "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "argumentsUnsupportedOption": "Saved Arguments contain an unsupported option ({{option}}).", + "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", + "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", + "argumentsPositionalPrompt": "Saved Arguments include a prompt.", + "editSavedArguments": "Edit them in Settings > Agents > Arguments." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 1e555c676d64..f586a2db15e2 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -17,6 +17,11 @@ export const AGENT_SESSION_FAILURE_COPY = { sendAgainToTryOnceMore: 'Send your message again to try once more.', couldNotStart: "{{agent}} couldn't start.", couldNotRestart: "{{agent}} couldn't restart.", + argumentsUnsupportedOption: 'Saved Arguments contain an unsupported option ({{option}}).', + argumentsMissingValue: 'Saved Arguments need a value for {{option}}.', + argumentsMultipleValues: 'Saved Arguments give {{option}} more than one value.', + argumentsPositionalPrompt: 'Saved Arguments include a prompt.', + editSavedArguments: 'Edit them in Settings > Agents > Arguments.', terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, quitTerminalAgent: QUIT_TERMINAL_AGENT, startNewChat: START_NEW_CHAT, @@ -92,6 +97,7 @@ export type AgentSessionFailureCopyValues = { agent?: string command?: string detail?: string + option?: string limit?: string size?: string } diff --git a/src/shared/agent-session-failure-fact-schema.ts b/src/shared/agent-session-failure-fact-schema.ts index c8e574490660..4df9fc459eb5 100644 --- a/src/shared/agent-session-failure-fact-schema.ts +++ b/src/shared/agent-session-failure-fact-schema.ts @@ -6,5 +6,8 @@ import { z } from 'zod' export const AgentSessionFailureFactSchema = z.object({ kind: z.string().min(1), detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), - refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() + refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional(), + argumentProblem: z + .object({ agent: z.string(), option: z.string(), problem: z.string() }) + .optional() }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 74e6e543b533..5ffce55ce893 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -142,6 +142,20 @@ function startRetry( function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { return (context, fact, _surface, say) => { const failed = say(verb, agent(say, context)) + if (fact.argumentProblem) { + const problemCopy = { + unsupportedOption: 'argumentsUnsupportedOption', + missingValue: 'argumentsMissingValue', + multipleValues: 'argumentsMultipleValues', + positionalPrompt: 'argumentsPositionalPrompt' + } as const + return joinSentences([ + failed, + say(problemCopy[fact.argumentProblem.problem], { option: fact.argumentProblem.option }), + say('editSavedArguments'), + ...startRetry(say, context) + ]) + } // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. if (fact.refusal?.details?.reason === 'claimConflicted') { return joinSentences([failed, say('terminalAgentHoldsChat'), say('quitTerminalAgent')]) diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index 0e718c3132bb..048d33c995b3 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -9,6 +9,7 @@ import { readWholeAgentSessionFailureFact, withProviderDiagnostic } from './agent-session-failure' +import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details' describe('provider diagnostics', () => { @@ -62,6 +63,31 @@ describe('reading a failure fact', () => { }) }) + it('retains safe argument details through schema admission and falls back for unknown details', () => { + const fact = { + kind: 'startFailed', + argumentProblem: { agent: 'Codex', option: '--remote', problem: 'unsupportedOption' } + } + expect(readWholeAgentSessionFailureFact(AgentSessionFailureFactSchema.parse(fact))).toEqual( + fact + ) + expect(readWholeAgentSessionFailureFact({ kind: 'startFailed' })).toEqual({ + kind: 'startFailed' + }) + for (const argumentProblem of [ + { agent: 'Future', option: '--remote', problem: 'unsupportedOption' }, + { agent: 'Codex', option: '--remote', problem: 'futureProblem' }, + { agent: 'Codex', option: '--remote=private', problem: 'unsupportedOption' } + ]) { + expect( + readWholeAgentSessionFailureFact({ kind: 'startFailed', argumentProblem }) + ).toBeUndefined() + expect(readAgentSessionFailureFact({ kind: 'startFailed', argumentProblem })).toEqual({ + kind: 'startFailed' + }) + } + }) + it('keeps what a provider said it is retrying, and only that', () => { expect( readAgentSessionFailureFact({ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index a3c97850a1ea..401c9dd462f6 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -90,6 +90,13 @@ export type ProviderDiagnostic = { audience: ProviderDiagnosticAudience } +/** Orca's validated saved Arguments refusal. The option has no value or user-authored operand. */ +export type AgentSessionArgumentProblem = { + agent: 'Codex' | 'Claude' + option: string + problem: 'unsupportedOption' | 'missingValue' | 'multipleValues' | 'positionalPrompt' +} + /** Stderr can be a whole dump; the row keeps enough to act on. The same cap as the exit reason a * lease record keeps, so a diagnostic never outgrows what the record may store. */ export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 512 @@ -134,6 +141,8 @@ export type AgentSessionFailureFact = { attachment?: AgentSessionAttachmentProblem /** On `providerRetrying`: why the provider is retrying. */ retry?: AgentSessionProviderRetry + /** A safe option name from Orca's saved Arguments parser, never an error message. */ + argumentProblem?: AgentSessionArgumentProblem } /** A fact as a row stores it: its kind may be one a newer host added, so only @@ -165,6 +174,7 @@ export function agentSessionFailureFact( refusal?: AgentSessionRefusalReference attachment?: AgentSessionAttachmentProblem retry?: AgentSessionProviderRetry + argumentProblem?: AgentSessionArgumentProblem } = {} ): AgentSessionFailureFact & { kind: TKind } { // Re-bounded here, so no writer can store more than the cap however it built the detail. @@ -176,7 +186,8 @@ export function agentSessionFailureFact( ...(detail ? { detail } : {}), ...(extra.refusal ? { refusal: extra.refusal } : {}), ...(extra.attachment ? { attachment: extra.attachment } : {}), - ...(extra.retry ? { retry: extra.retry } : {}) + ...(extra.retry ? { retry: extra.retry } : {}), + ...(extra.argumentProblem ? { argumentProblem: extra.argumentProblem } : {}) } } @@ -206,6 +217,29 @@ function readAttachmentProblem(value: unknown): AgentSessionAttachmentProblem | : { reason } } +function readArgumentProblem(value: unknown): AgentSessionArgumentProblem | undefined { + if (!isRecord(value)) { + return undefined + } + const agent = value.agent + const problem = value.problem + const option = value.option + if ( + (agent !== 'Codex' && agent !== 'Claude') || + (problem !== 'unsupportedOption' && + problem !== 'missingValue' && + problem !== 'multipleValues' && + problem !== 'positionalPrompt') || + typeof option !== 'string' || + (problem === 'positionalPrompt' + ? option !== 'prompt' + : !/^(?:--[a-zA-Z][a-zA-Z0-9-]{0,63}|-[a-zA-Z]|--\?)$/.test(option)) + ) { + return undefined + } + return { agent, option, problem } +} + /** A retry as a reader meets it; undefined when it names no field. */ export function readProviderRetry(value: unknown): AgentSessionProviderRetry | undefined { if (!isRecord(value)) { @@ -233,11 +267,13 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure const refusal = readAgentSessionRefusalReference(value.refusal) const attachment = readAttachmentProblem(value.attachment) const retry = readProviderRetry(value.retry) + const argumentProblem = readArgumentProblem(value.argumentProblem) return agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), ...(refusal ? { refusal } : {}), ...(attachment ? { attachment } : {}), - ...(retry ? { retry } : {}) + ...(retry ? { retry } : {}), + ...(argumentProblem ? { argumentProblem } : {}) }) } From e022bfd3e23ce4c25e52af20a69a85942de131f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:54:38 -0700 Subject: [PATCH 4/9] refactor(native-chat): require the current launch arguments source --- src/main/claude/claude-agent-sdk-contract-pins.test.ts | 1 + src/main/claude/claude-structured-auth-parity.test.ts | 2 ++ .../claude/claude-structured-launch-resolution.test.ts | 8 +++++++- src/main/claude/claude-structured-launch-resolution.ts | 6 ++---- .../claude/claude-structured-turn-resume-point.test.ts | 1 + src/main/codex/codex-structured-launch-resolution.test.ts | 4 +++- src/main/codex/codex-structured-launch-resolution.ts | 6 ++---- src/main/runtime/structured-agent-session-runtime.ts | 4 +--- src/main/runtime/structured-claude-runtime-adapter.ts | 2 +- 9 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index f6f588a07f59..661dc6b7b6f1 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -159,6 +159,7 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [ launchArgs } as unknown as AgentSessionRecord return createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => launchArgs, store: { getRecord: () => record } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index aa535a12a321..c951d2791b66 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -43,6 +43,7 @@ function resolverFor(options: { authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -68,6 +69,7 @@ function realResolverAdapter( } as unknown as AgentSessionRecord return new ClaudeStructuredSessionAdapter({ resolveLaunch: createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => resumable } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index b34c0e30a232..60d5888e1196 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -70,7 +70,7 @@ function resolverFor( resolveAuthPolicy: () => ({ stripAuthEnv }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, - ...(resolveLaunchArgs ? { resolveLaunchArgs } : {}), + resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), ...(resolveEnv ? { resolveEnv } : {}) }) } @@ -411,6 +411,7 @@ describe('claude structured launch resolution', () => { it('builds on the supplied inherited env instead of Orca process env', async () => { const launch = await createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -423,6 +424,7 @@ describe('claude structured launch resolution', () => { it('drops an inherited CLAUDE_CONFIG_DIR so the record stays the only Claude home the pin sees', async () => { const launch = await createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -441,6 +443,7 @@ describe('claude structured launch resolution', () => { it('keeps a configured overlay CLAUDE_CONFIG_DIR over the dropped inherited one', async () => { const launch = await createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -454,6 +457,7 @@ describe('claude structured launch resolution', () => { it('still strips an inherited auth key under a managed account', async () => { const launch = await createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -491,6 +495,7 @@ describe('claude structured launch resolution', () => { makeExecutable(nodeCommand) const launch = await createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => claudeCommand, @@ -533,6 +538,7 @@ describe('claude structured launch resolution', () => { describe('managed-account gate on every acquisition', () => { function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { return createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 7944e82d05d1..f6aa69f0bd78 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -113,7 +113,7 @@ export type ClaudeStructuredLaunch = { export type ClaudeStructuredLaunchResolverDeps = { store: AgentSessionRecordStore - resolveLaunchArgs?: () => Promise | string[] + resolveLaunchArgs: () => Promise | string[] resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string resolveEnv?: () => @@ -290,9 +290,7 @@ export function createClaudeStructuredLaunchResolver( providerSessionId, claudeConfigDir: record.accountHome.path }))) - const configuredArgs = claudeStructuredLaunchArgs( - (await deps.resolveLaunchArgs?.()) ?? record.launchArgs ?? [] - ) + const configuredArgs = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs()) const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index 3ffd74aff14f..f4538da4b07b 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -138,6 +138,7 @@ describe('Claude durable resume point at turn end', () => { expect(head).toMatchObject({ resumeCursor: 'a2' }) const resolve = createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only getRecord from its store. store: { getRecord: () => store.record } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 33fc26f77a44..4f3ed962effa 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -50,7 +50,7 @@ function resolverFor( resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, - ...(resolveLaunchArgs ? { resolveLaunchArgs } : {}), + resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), isWindowsProcessStartTimeAvailable: () => true, resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs }) }) @@ -113,6 +113,7 @@ describe('codex structured launch resolution', () => { await withPlatform('win32', async () => { const resolveLaunch = createCodexStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, resolveCommand: () => command, @@ -130,6 +131,7 @@ describe('codex structured launch resolution', () => { await withPlatform('win32', async () => { const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`) const resolveLaunch = createCodexStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath, isWindowsProcessStartTimeAvailable: () => false diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index a3ceeb12e12c..2906e1a25115 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -19,7 +19,7 @@ import { codexStructuredLaunchArgs } from './codex-structured-launch-args' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore - resolveLaunchArgs?: () => Promise | string[] + resolveLaunchArgs: () => Promise | string[] /** Absolute path of a workspace on this host. Rejects when the workspace no * longer resolves, which is the case a stale mobile client hits. */ resolveWorkspacePath: (workspaceId: string) => Promise @@ -91,9 +91,7 @@ export function createCodexStructuredLaunchResolver( throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) } const { command, environment } = await resolveCodexStructuredInvocation(deps) - const args = codexStructuredLaunchArgs( - (await deps.resolveLaunchArgs?.()) ?? record.launchArgs ?? [] - ) + const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs()) const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) // A Codex record's chain holds only Codex handles; the record store refuses anything else. diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3c19f4a4833b..ed07bd7f0059 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -258,9 +258,7 @@ async function installOnJournal( store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: resolveCodexEnvironment, - ...(deps.resolveLaunchArgs - ? { resolveLaunchArgs: () => deps.resolveLaunchArgs!('codex') } - : {}), + resolveLaunchArgs: () => deps.resolveLaunchArgs?.('codex') ?? [], ...(deps.resolveCodexPermissionPolicy ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } : {}), diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 2e27d019604b..9225121b23ac 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -84,7 +84,7 @@ export function createStructuredClaudeRuntimeAdapter( resolveLaunch: createClaudeStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, - ...(deps.resolveClaudeLaunchArgs ? { resolveLaunchArgs: deps.resolveClaudeLaunchArgs } : {}), + resolveLaunchArgs: deps.resolveClaudeLaunchArgs ?? (() => []), resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), ...(deps.resolveClaudeInheritedEnv From 16894d2d8996a5e5cf7534c906c20c8bc3e75b54 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:07:25 -0700 Subject: [PATCH 5/9] fix(native-chat): require saved arguments when installing the runtime --- ...ude-structured-session-integration.test.ts | 1 + ...tructured-agent-launch-args-wiring.test.ts | 37 +++++++++++++++++++ ...gent-session-codex-interrupt-order.test.ts | 1 + ...ent-session-codex-settlement-order.test.ts | 1 + ...-session-codex-turn-end-settlement.test.ts | 1 + ...ured-agent-session-failure-logging.test.ts | 2 + ...ructured-agent-session-held-signal.test.ts | 1 + ...uctured-agent-session-host-refusal.test.ts | 1 + ...ured-agent-session-install-failure.test.ts | 1 + ...ructured-agent-session-integration.test.ts | 1 + ...gent-session-record-import-install.test.ts | 1 + ...uctured-agent-session-runtime-exit.test.ts | 5 +++ .../structured-agent-session-runtime.test.ts | 2 + .../structured-agent-session-runtime.ts | 15 +++++--- ...nt-session-spawn-token-descendants.test.ts | 1 + ...ed-agent-session-startup-reconcile.test.ts | 1 + ...-agent-session-startup-tab-restore.test.ts | 1 + ...-chat-coordinator-mail-rig.test-fixture.ts | 1 + .../structured-child-records-switch.test.ts | 1 + .../structured-claude-pending-rewind.test.ts | 1 + .../structured-claude-runtime-adapter.ts | 4 +- ...ed-claude-scripted-runtime-test-support.ts | 1 + ...tructured-codex-child-work-runtime.test.ts | 1 + ...ructured-codex-session-rpc-test-harness.ts | 1 + 24 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 src/main/runtime/structured-agent-launch-args-wiring.test.ts diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index bbafd5f1aae4..c10fb8bf0f44 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -249,6 +249,7 @@ beforeEach(async () => { // Hermetic: never the developer's real login shell. resolveEnvironment: async () => shellEnv, resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => claudeAuthPolicy, openClaudeConnection: claude.openConnection, // Production's sink wiring onto a real hook server, whose records a Stop reaches. diff --git a/src/main/runtime/structured-agent-launch-args-wiring.test.ts b/src/main/runtime/structured-agent-launch-args-wiring.test.ts new file mode 100644 index 000000000000..6037c26e5732 --- /dev/null +++ b/src/main/runtime/structured-agent-launch-args-wiring.test.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { afterEach, expect, it } from 'vitest' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime, + STRUCTURED_AGENT_LAUNCH_ARGS_REQUIRED +} from './structured-agent-session-runtime' + +let stateDirectory: string | undefined + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = undefined + } +}) + +it('refuses installation when the host omits the saved Arguments source', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-launch-args-wiring-')) + stateDirectory = root + + await expect( + // @ts-expect-error Exercise an unchecked caller that dropped the required resolver. + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + logger: createStructuredAgentSessionLogger() + }) + ).rejects.toThrow(STRUCTURED_AGENT_LAUNCH_ARGS_REQUIRED) +}) diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index de09e72b7e5c..c3d6e081004d 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -186,6 +186,7 @@ beforeEach(async () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index 47a8c609db20..13b70638b95a 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -160,6 +160,7 @@ beforeEach(async () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 712e05552217..c268c7c8e85d 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -277,6 +277,7 @@ beforeEach(async () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 14efe114f903..80683c79242a 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -308,6 +308,7 @@ describe('installing the runtime', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) } as unknown as Parameters[0] ) @@ -324,6 +325,7 @@ describe('installing the runtime', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), logger: { warn: () => { diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 0bf61aa7843c..aa3cf51f132d 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -42,6 +42,7 @@ function installHost(): Promise { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}), logger: createStructuredAgentSessionLogger() diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index e8296141b3be..50c22f15b7dd 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -44,6 +44,7 @@ function install(): ReturnType { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) } diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index c00e9c6890f2..c5fcba57991b 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -39,6 +39,7 @@ function install(): ReturnType { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) } diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 5ff7cb3029f7..92510aa3241a 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -346,6 +346,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => { bootEnvironmentReads += 1 diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts index 5c2256ad8827..8495fb49f5de 100644 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -80,6 +80,7 @@ function startupRuntime(log: ReturnType root, resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 706b4de5bce5..d9562e2ffefe 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -87,6 +87,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), @@ -192,6 +193,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), @@ -223,6 +225,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), @@ -297,6 +300,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), @@ -404,6 +408,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index f572e8909833..4b53ed51d09a 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -264,6 +264,7 @@ describe('structured agent-session runtime install', () => { hostId: HOST_ID, claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}) }) @@ -305,6 +306,7 @@ describe('structured agent-session runtime install', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), readProcessStartTime: async () => 1_700_000_000_000 }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index ed07bd7f0059..b0dd21750a0a 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -101,7 +101,8 @@ export type StructuredAgentSessionRuntimeDeps = { openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] - resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] + /** Required, and asserted at install time — saved Arguments must never be silently omitted. */ + resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record resolveClaudeLaunchEnv?: () => Promise> | Record @@ -138,6 +139,9 @@ let installing: Promise | null = null export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = 'structured agent-session host requires a Claude auth policy resolver' +export const STRUCTURED_AGENT_LAUNCH_ARGS_REQUIRED = + 'structured agent-session host requires a launch arguments resolver' + /** Thrown when the host is installed without a logger: every failure it carries on past would * otherwise reach nobody. */ export const STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED = @@ -217,6 +221,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise | undefined = deps.logger if (typeof declared?.warn !== 'function' || typeof declared.error !== 'function') { throw new Error(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) @@ -258,7 +265,7 @@ async function installOnJournal( store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: resolveCodexEnvironment, - resolveLaunchArgs: () => deps.resolveLaunchArgs?.('codex') ?? [], + resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), ...(deps.resolveCodexPermissionPolicy ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } : {}), @@ -285,9 +292,7 @@ async function installOnJournal( ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), resolveClaudeInheritedEnv, - ...(deps.resolveLaunchArgs - ? { resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs!('claude') } - : {}), + resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs('claude'), resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 16cb94a812ff..7ebc52cd89a7 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -142,6 +142,7 @@ describe('a process that inherited a spawn token', () => { hostId: HOST_ID, claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}) }) diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 43d457790e31..ec800315ed6c 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -80,6 +80,7 @@ function startupRuntime(log = recordingStructuredAgentSessionLogger()) { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index c01c30b6b896..bed708ae29a8 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -218,6 +218,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) options.afterInstall?.() diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 6fadd54a13be..fc5928df20b6 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -254,6 +254,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({ PATH: '/usr/bin' }), openCodexConnection: codex.openConnection, diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index eaf5644164f8..9e9eddb7f855 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -123,6 +123,7 @@ describe('the chat strip and the session list read the same host child records', hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 47fa6d201009..404852a6804a 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -115,6 +115,7 @@ beforeEach(async () => { store, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveClaudeCommand: () => '/usr/local/bin/claude', + resolveClaudeLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), openClaudeConnection: claude.openConnection, readProcessStartTime: async () => HOST_TEST_NOW, diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9225121b23ac..76d6c51542a8 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -22,7 +22,7 @@ import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise - resolveClaudeLaunchArgs?: () => Promise | string[] + resolveClaudeLaunchArgs: () => Promise | string[] resolveClaudeCommand?: () => string resolveClaudeLaunchEnv?: () => Promise> | Record /** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */ @@ -84,7 +84,7 @@ export function createStructuredClaudeRuntimeAdapter( resolveLaunch: createClaudeStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, - resolveLaunchArgs: deps.resolveClaudeLaunchArgs ?? (() => []), + resolveLaunchArgs: deps.resolveClaudeLaunchArgs, resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), ...(deps.resolveClaudeInheritedEnv diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 7a69cbe9c86d..b379afbe1e23 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -230,6 +230,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { claimKeyId: 'key-1', resolveWorkspacePath: async () => directory, resolveClaudeCommand: () => '/usr/local/bin/claude', + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), openClaudeConnection: openConnection, readProcessStartTime diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 5e0cf36f14d7..3bf2dbf7d19d 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -76,6 +76,7 @@ describe('structured Codex child work through the production runtime', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index 81e7d3f1d8c4..52929392d888 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -213,6 +213,7 @@ export async function openStructuredCodexRpcHarness( claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({ PATH: '/shell/bin:/usr/bin', From 7216560726b7504eb276165cade0903af1f6cfde Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:18:35 -0700 Subject: [PATCH 6/9] fix(native-chat): show saved argument refusals beside launch retry --- ...red-agent-session-failed-create-refusal.ts | 10 ++- ...ent-session-pre-spawn-first-answer.test.ts | 23 +++++-- .../native-chat/NativeChatLaunchRetry.tsx | 4 +- ...tructuredSession.launch-lifecycle.test.tsx | 22 +++++++ src/shared/agent-session-argument-problem.ts | 38 ++++++++++++ src/shared/agent-session-failure.ts | 38 +++--------- src/shared/agent-session-refusal-details.ts | 13 +++- .../agent-session-refusal-notice.test.ts | 61 +++++++++++++++++++ src/shared/agent-session-refusal-notice.ts | 15 +++++ src/shared/agent-session-write-failure.ts | 8 ++- 10 files changed, 190 insertions(+), 42 deletions(-) create mode 100644 src/shared/agent-session-argument-problem.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index a59482c9c6bf..3244ef1e07fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -110,6 +110,10 @@ function isExitProvenAcquisitionFailure(error: unknown): error is Error { function failedAcquisitionDetails( error: unknown ): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { + const argumentProblem = argumentProblemOf(error) + if (argumentProblem) { + return { reason: 'attachFailed', argumentProblem } + } if (error instanceof AgentSessionAcquisitionRefusal) { return { reason: error.reason } } @@ -129,7 +133,11 @@ export function failedAcquisitionRefusal( wording: FailedAcquisitionWording ): { ok: false; refusal: AgentSessionWireRefusal } | null { // A proven exit is a settled failure, answered in the shape its ledger row replays. - if (error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error)) { + if ( + error instanceof AgentSessionAcquisitionRefusal || + isExitProvenAcquisitionFailure(error) || + (isAgentSessionPreSpawnError(error) && argumentProblemOf(error)) + ) { return { ok: false, refusal: failedAcquisitionWireRefusal(failedAcquisitionDetails(error), wording, error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 7bc5de6639cc..dbee700a235a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -99,12 +99,15 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { onAttached: () => {} } const first = await performAttach(input).then( - () => null, + (result) => result, (error: unknown) => error ) - expect(first).toBeInstanceOf(AgentSessionPreSpawnError) + if (first instanceof Error) { + expect(first).toBeInstanceOf(AgentSessionPreSpawnError) + } return { - first: mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first), + first: + first instanceof Error ? mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first) : first, replay: await performAttach(input) } } @@ -118,8 +121,18 @@ describe('a create that fails before any process spawns', () => { ) const sentence = "Claude couldn't start. Saved Arguments give --model more than one value. Edit them in Settings > Agents > Arguments. Send your message to try again." - expect(first).toMatchObject({ ok: false, error: { message: sentence } }) - expect(replay).toMatchObject({ ok: false, refusal: { message: sentence } }) + for (const result of [first, replay]) { + expect(result).toMatchObject({ + ok: false, + refusal: { + message: sentence, + details: { + reason: 'attachFailed', + argumentProblem: { agent: 'Claude', option: '--model', problem: 'multipleValues' } + } + } + }) + } expect(JSON.stringify([first, replay])).not.toContain('private') }) diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx index c3984b6ef549..364c1bd8e408 100644 --- a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx @@ -39,10 +39,12 @@ export function NativeChatLaunchRetry({ agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {}) ) : '' + const saysStartFailure = + failure?.code === 'agent_session_operation_invalid' && failure.details?.argumentProblem return (
- {cause ? joinSentences([message, cause]) : message} + {cause ? (saysStartFailure ? cause : joinSentences([message, cause])) : message}