diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 0e9c350c5ec..245a830f751 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -504,10 +504,14 @@ describe('caller-supplied launch inputs', () => { expect(h.createStructuredSession).not.toHaveBeenCalled() }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // Command values never change the selected chat surface. it.each([ ['claude', 'claude-wrapper'], - ['codex', 'codex-nightly'] + ['codex', 'codex-nightly'], + ['claude', 'npx claude'], + ['codex', 'wrapper --arg'], + ['claude', '/missing/claude'], + ['codex', './codex'] ] as const)('opens a structured %s session despite launch command %s', async (agent, command) => { const h = harness({ settings: { ...STRUCTURED_PREFERENCE, agentCmdOverrides: { [agent]: command } } @@ -587,7 +591,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 () => { @@ -597,7 +601,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 755bd55e276..0447c4c6366 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 - * (the Agent SDK and app-server version their option sets independently of the interactive CLI's, - * and the launch command names the CLI binary, so both apply to terminal launches only), 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/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index e16c1420ef1..acbd988c614 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -146,6 +146,7 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [ launchArgs } as unknown as AgentSessionRecord return createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => launchArgs, store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, @@ -329,7 +330,18 @@ 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', + '--add-dir', + '/repo/one', + '/repo/two', + '--add-dir=/repo/three' + ]) await drainQuery({ ...launch.options, pathToClaudeCodeExecutable: FAKE_CLI, @@ -346,9 +358,15 @@ 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.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') // 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 25a9a28653b..3f7792b33de 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -6,6 +6,7 @@ import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-proces 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 @@ -16,14 +17,17 @@ type DiscoverResult = Awaited> const AUTH_POLICY = { stripAuthEnv: false } as const -function probeDeps(): { +function probeDeps(command = `"${process.execPath}"`): { resolveCommand: () => string resolveEnv: () => Record resolveInheritedEnv: () => Promise> resolveAuthPolicy: () => typeof AUTH_POLICY } { return { - resolveCommand: () => '/resolved/claude with spaces/claude', + resolveCommand: () => + resolveStructuredAgentCommand('claude', { + agentCmdOverrides: { claude: command } + }), resolveEnv: () => ({ ANTHROPIC_MODEL_GATEWAY: 'https://gateway.example' }), resolveInheritedEnv: async () => ({ PATH: '/resolved/bin', HOME: '/homes/user' }), resolveAuthPolicy: () => AUTH_POLICY @@ -55,53 +59,56 @@ function listedResult(): DiscoverResult { } describe('claude model catalog probe', () => { - it('lists under the same resolved command and env as a structured session launch', async () => { - const deps = probeDeps() - const captured: DiscoverInput[] = [] - const spawnAgent = vi.fn( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake discover below never reads the returned child. - () => ({}) as SpawnedSourceControlAgentProcess - ) - const probe = createClaudeModelCatalogProbe({ - ...deps, - spawnAgent, - discover: async (input) => { - captured.push(input) - // Drive the probe's spawn wrapper once, the way the real listing would. - input.spawnAgent({ - binary: 'claude', - args: ['--list'], - env: input.env, - stdinMode: 'ignore', - useCwdForNative: false - }) - return listedResult() - } - }) - const success = await probe('/homes/account-a') - expect(success.origin).toBe('probe') - expect(success.models).toEqual([ - { - id: 'sonnet', - label: 'Sonnet', - isDefault: true, - // The spec's `defaultThinkingLevel` is not what Claude runs; naming it would label the effort. - efforts: [{ value: 'low', label: 'Low' }] - } - ]) - // The probe's env is exactly the session launch's resolved env for the - // same deps, plus the account pin — and the spawn runs the resolved - // binary, never the bare spec name. - const invocation = await resolveClaudeStructuredInvocation(deps, (env) => ({ - ...env, - CLAUDE_CONFIG_DIR: '/homes/account-a' - })) - expect(captured).toHaveLength(1) - expect(captured[0]!.env).toEqual(invocation.env) - expect(captured[0]!.agentCommandOverride).toBeUndefined() - expect(spawnAgent).toHaveBeenCalledTimes(1) - expect(spawnAgent.mock.calls[0]![0].binary).toBe(invocation.command) - }) + it.each([`"${process.execPath}"`, 'npx claude', '/missing/claude', './claude'])( + 'lists with the session executable for Command %s', + async (command) => { + const deps = probeDeps(command) + const captured: DiscoverInput[] = [] + const spawnAgent = vi.fn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake discover below never reads the returned child. + () => ({}) as SpawnedSourceControlAgentProcess + ) + const probe = createClaudeModelCatalogProbe({ + ...deps, + spawnAgent, + discover: async (input) => { + captured.push(input) + // Drive the probe's spawn wrapper once, the way the real listing would. + input.spawnAgent({ + binary: 'claude', + args: ['--list'], + env: input.env, + stdinMode: 'ignore', + useCwdForNative: false + }) + return listedResult() + } + }) + const success = await probe('/homes/account-a') + expect(success.origin).toBe('probe') + expect(success.models).toEqual([ + { + id: 'sonnet', + label: 'Sonnet', + isDefault: true, + // The spec's `defaultThinkingLevel` is not what Claude runs; naming it would label the effort. + efforts: [{ value: 'low', label: 'Low' }] + } + ]) + // The probe's env is exactly the session launch's resolved env for the + // same deps, plus the account pin — and the spawn runs the resolved + // binary, never the bare spec name. + const invocation = await resolveClaudeStructuredInvocation(deps, (env) => ({ + ...env, + CLAUDE_CONFIG_DIR: '/homes/account-a' + })) + expect(captured).toHaveLength(1) + expect(captured[0]!.env).toEqual(invocation.env) + expect(captured[0]!.agentCommandOverride).toBeUndefined() + expect(spawnAgent).toHaveBeenCalledTimes(1) + expect(spawnAgent.mock.calls[0]![0].binary).toBe(invocation.command) + } + ) it('pins no CLAUDE_CONFIG_DIR for the CLI default home, exactly as a session spawn does', async () => { const envs: DiscoverInput['env'][] = [] diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index c445e4837f2..227fb117914 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -42,6 +42,7 @@ function resolverFor(options: { authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -67,6 +68,7 @@ function realResolverAdapter( } as unknown as AgentSessionRecord return new ClaudeStructuredSessionAdapter({ resolveLaunch: createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', 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 00000000000..6a788b836c9 --- /dev/null +++ b/src/main/claude/claude-structured-launch-args.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' + +describe('Claude structured launch arguments', () => { + it('translates long flags, equals values, and the model short flag', () => { + expect( + claudeStructuredLaunchArgs([ + '-m', + 'opus', + '--effort=high', + '--chrome', + '--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( + claudeStructuredLaunchArgs([ + '-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({ extraArgs: { model: 'opus' }, additionalDirectories: [] }) + }) + + it('ignores positional prompts and tokens after --', () => { + expect( + claudeStructuredLaunchArgs(['prompt', '--model', 'opus', '--', '--effort', 'high']) + ).toEqual({ + 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 new file mode 100644 index 00000000000..1a4ac11b1da --- /dev/null +++ b/src/main/claude/claude-structured-launch-args.ts @@ -0,0 +1,85 @@ +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', + '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' +} + +/** 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 === '--') { + 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 values = separator === -1 ? [] : [arg.slice(separator + 1)] + while (args[index + 1] !== undefined && !args[index + 1]!.startsWith('-')) { + values.push(args[++index]!) + } + if (OWNED_FLAGS.has(flag)) { + continue + } + if (flag === 'add-dir') { + if (values.length === 0 || values.some((value) => !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, additionalDirectories } +} diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index c280553cfe5..5427756baf2 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -62,7 +62,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, pinLaunchDirectory: vi.fn() }, @@ -71,6 +72,7 @@ function resolverFor( resolveAuthPolicy: () => ({ stripAuthEnv }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, + resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), ...(resolveEnv ? { resolveEnv } : {}) }) } @@ -304,18 +306,75 @@ 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('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', + '--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 + }) }) it('keeps the session launch environment pinned after account settings change', async () => { @@ -367,6 +426,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(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -379,6 +439,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(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -397,6 +458,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(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -410,6 +472,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(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -447,6 +510,7 @@ describe('claude structured launch resolution', () => { makeExecutable(nodeCommand) const launch = await createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => claudeCommand, @@ -489,6 +553,7 @@ describe('claude structured launch resolution', () => { describe('managed-account gate on every acquisition', () => { function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { return createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => RESUMABLE, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', @@ -541,11 +606,13 @@ describe('readable Claude thinking', () => { const launchWith = ( thinkingDisplay?: ClaudeStructuredLaunchResolverDeps['thinkingDisplay'], authSwitchSettleTimeoutMs?: number, - command = '/usr/local/bin/claude' + command = '/usr/local/bin/claude', + launchArgs: string[] = [] ) => createClaudeStructuredLaunchResolver({ store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveLaunchArgs: () => launchArgs, resolveCommand: () => command, resolveAuthPolicy: () => ({ stripAuthEnv: false }), resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), @@ -601,6 +668,20 @@ describe('readable Claude thinking', () => { expect((await launchWith()).options.extraArgs).toEqual({ 'replay-user-messages': null }) }) + it('keeps saved Arguments beside readable thinking, with the display left to Orca', async () => { + const launch = await launchWith( + { argsFor: async () => ({ 'thinking-display': 'summarized' }) }, + undefined, + undefined, + ['--effort', 'high', '--thinking-display', 'omitted'] + ) + expect(launch.options.extraArgs).toEqual({ + effort: 'high', + 'replay-user-messages': null, + 'thinking-display': 'summarized' + }) + }) + it('still rechecks an account switch that began while the probe ran', async () => { try { const launch = launchWith( diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index dc33d652e4d..4b03c941018 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 { claudeStructuredLaunchArgs } from './claude-structured-launch-args' import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' @@ -48,6 +49,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'settingSources' | 'supportedDialogKinds' | 'extraArgs' + | 'additionalDirectories' | 'model' | 'effort' | 'permissionMode' @@ -114,6 +116,7 @@ export type ClaudeStructuredLaunch = { export type ClaudeStructuredLaunchResolverDeps = { store: Pick + resolveLaunchArgs: () => Promise | string[] resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string resolveEnv?: () => @@ -343,8 +346,8 @@ 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 configured = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs()) + const { additionalDirectories } = configured const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) @@ -366,7 +369,9 @@ export function createClaudeStructuredLaunchResolver( options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, + ...(additionalDirectories.length ? { additionalDirectories } : {}), extraArgs: { + ...configured.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs, ...thinkingDisplayArgs 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 3ffd74aff14..f4538da4b07 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-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index 87c615d410b..d7f3edff47b 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', @@ -12,50 +13,59 @@ const MODEL_ROW = { } describe('codex model catalog probe', () => { - it('spawns with the same resolved command and env as a structured session launch', async () => { - // The env a user's shell/config resolves for sessions, PATH included. - const resolveEnvironment = async (): Promise => ({ - PATH: '/resolved/bin', - HOME: '/homes/user', - OPENAI_BASE_URL: 'https://gateway.example', - DROPPED: undefined - }) - 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' - }) - const invocations: CodexAppServerInvocation[] = [] - const probe = createCodexModelCatalogProbe({ - resolveEnvironment, - resolveCommand, - runSession: async (invocation, body) => { - invocations.push(invocation) - return body({ - request: async () => ({ data: [MODEL_ROW], nextCursor: null }), - notify: () => {} - }) - } - }) - const success = await probe('/homes/account-a') - expect(success.origin).toBe('probe') - expect(success.models.map((model) => model.id)).toEqual(['gpt-live']) - // The session launch resolves the exact same invocation for the same deps. - const sessionInvocation = await resolveCodexStructuredInvocation({ - resolveEnvironment, - resolveCommand - }) - expect(invocations).toHaveLength(1) - expect(invocations[0]!.cliPath).toBe(sessionInvocation.command) - expect(invocations[0]!.env).toEqual({ - PATH: '/resolved/bin', - HOME: '/homes/user', - OPENAI_BASE_URL: 'https://gateway.example', - CODEX_HOME: '/homes/account-a' - }) - // A short-lived probe must not start plugin marketplace clones that outlive its teardown. - expect(invocations[0]!.args.join(' ')).toContain('features.plugins=false') - }) + it.each([`"${process.execPath}"`, 'npx codex', '/missing/codex', './codex'])( + 'lists with the session executable for Command %s', + async (command) => { + // The env a user's shell/config resolves for sessions, PATH included. + const resolveEnvironment = async (): Promise => ({ + PATH: '/resolved/bin', + HOME: '/homes/user', + OPENAI_BASE_URL: 'https://gateway.example', + DROPPED: undefined + }) + const resolveCommand = vi.fn((options?: { pathEnv?: string | null; homePath?: string }) => { + expect(options?.pathEnv).toBe('/resolved/bin') + expect(options?.homePath).toBe('/homes/user') + return resolveStructuredAgentCommand( + 'codex', + { + agentCmdOverrides: { codex: command } + }, + options + ) + }) + const invocations: CodexAppServerInvocation[] = [] + const probe = createCodexModelCatalogProbe({ + resolveEnvironment, + resolveCommand, + runSession: async (invocation, body) => { + invocations.push(invocation) + return body({ + request: async () => ({ data: [MODEL_ROW], nextCursor: null }), + notify: () => {} + }) + } + }) + const success = await probe('/homes/account-a') + expect(success.origin).toBe('probe') + expect(success.models.map((model) => model.id)).toEqual(['gpt-live']) + // The session launch resolves the exact same invocation for the same deps. + const sessionInvocation = await resolveCodexStructuredInvocation({ + resolveEnvironment, + resolveCommand + }) + expect(invocations).toHaveLength(1) + expect(invocations[0]!.cliPath).toBe(sessionInvocation.command) + expect(invocations[0]!.env).toEqual({ + PATH: '/resolved/bin', + HOME: '/homes/user', + OPENAI_BASE_URL: 'https://gateway.example', + CODEX_HOME: '/homes/account-a' + }) + // A short-lived probe must not start plugin marketplace clones that outlive its teardown. + expect(invocations[0]!.args.join(' ')).toContain('features.plugins=false') + } + ) it('keeps the listing when config/read never answers', async () => { const server = String.raw` 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 00000000000..0b156acdd70 --- /dev/null +++ b/src/main/codex/codex-structured-launch-args.test.ts @@ -0,0 +1,103 @@ +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', () => { + 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 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 new file mode 100644 index 00000000000..90b39fffec8 --- /dev/null +++ b/src/main/codex/codex-structured-launch-args.ts @@ -0,0 +1,115 @@ +// 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', + '--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 StructuredAgentArgumentsError('Codex', option, 'unsupportedOption') + } + 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 StructuredAgentArgumentsError('Codex', option, 'missingValue') + } + 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 StructuredAgentArgumentsError('Codex', token, 'positionalPrompt') + } + 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 4fb64e33056..b4759911328 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -54,18 +54,57 @@ 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, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, + resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), 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('resumes a floating session in its pinned folder, not the current floating setting', async () => { const pinned = mkdtempSync(join(tmpdir(), 'orca-codex-floating-')) const resolveWorkspacePath = vi.fn(async () => '/floating/current-setting') @@ -90,6 +129,7 @@ describe('codex structured launch resolution', () => { }), pinLaunchDirectory }, + resolveLaunchArgs: () => [], resolveWorkspacePath: async () => '/floating/start-folder', resolveCommand: () => '/usr/local/bin/codex' }) @@ -119,6 +159,7 @@ describe('codex structured launch resolution', () => { await withPlatform('win32', async () => { const resolveLaunch = createCodexStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, resolveCommand: () => command @@ -135,6 +176,7 @@ describe('codex structured launch resolution', () => { isWindowsProcessStartTimeAvailable.mockReturnValue(false) await withPlatform('win32', async () => { const resolveLaunch = createCodexStructuredLaunchResolver({ + resolveLaunchArgs: () => [], store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, resolveCommand: () => 'codex.exe' @@ -190,8 +232,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' @@ -236,14 +277,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 () => { @@ -251,6 +305,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'] @@ -261,6 +316,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 723281c590a..183f765f5c8 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,10 +15,12 @@ import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-lau import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' +import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export type CodexStructuredLaunchResolverDeps = { store: Pick + 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 @@ -82,8 +84,7 @@ export function createCodexStructuredLaunchResolver( throw new Error(`codex sessions pin ${pinned}, 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(await deps.resolveLaunchArgs()) const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) // A Codex record's chain holds only Codex handles; the attach admission refuses anything else. @@ -92,7 +93,7 @@ export function createCodexStructuredLaunchResolver( const model = record.options?.model return { command, - args: ['app-server'], + args: [...args, 'app-server'], cwd: await resolveAgentSessionLaunchDirectory(deps, record), codexHome: accountHome.path, ...(environment ? { env: { ...environment } as Record } : {}), 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 7f7eb05fe1b..fb0150c7aba 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' import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -325,6 +326,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 b5a742b788b..85501dd8a31 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 c9a3a4a997a..5ba5e8f9539 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 @@ -266,7 +266,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 @@ -275,6 +279,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 90f52db3d09..3244ef1e07f 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: { @@ -104,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 } } @@ -123,10 +133,14 @@ 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) + 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 ccef42f57bd..b508cc9e757 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 6805ffd5ead..75f7769e7a7 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 = [ @@ -72,7 +74,7 @@ export function providerStartupFailureFact(cause?: unknown): SubmissionRejection } return agentSessionFailureFact( providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', - { detail: providerDiagnosticOf(cause) } + { detail: providerDiagnosticOf(cause), argumentProblem: argumentProblemOf(cause) } ) } @@ -93,7 +95,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) { @@ -104,6 +106,7 @@ function refusedStartFailureFact( } return agentSessionFailureFact(cause.newSession ? 'startFailed' : 'restartFailed', { detail: diagnostic, + argumentProblem, refusal: agentSessionRefusalReference(refusal) }) } @@ -112,7 +115,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 104933eecb7..e6c3cead931 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' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_800_000_000_000 @@ -100,17 +101,43 @@ 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) } } 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." + 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') + }) + 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 00000000000..f6fca945307 --- /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/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts new file mode 100644 index 00000000000..3fa758cfe5b --- /dev/null +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -0,0 +1,88 @@ +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 { resolveStructuredAgentCommand } from './structured-agent-command-resolution' +import { resolveCliCommand } from '../../shared/node-cli-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(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('uses the stock executable for missing files, directories, relative paths and shell lines', () => { + 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(resolveStructuredAgentCommand('claude', settings)).toBe(resolveCliCommand('claude')) + } + }) + + it.skipIf(process.platform === 'win32')('requires executable permission on Unix', () => { + const { command } = executable() + chmodSync(command, 0o644) + expect( + resolveStructuredAgentCommand('claude', { agentCmdOverrides: { claude: `"${command}"` } }) + ).toBe(resolveCliCommand('claude')) + }) +}) 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 00000000000..7ce9afcb77b --- /dev/null +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -0,0 +1,34 @@ +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 { 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 +} + +/** Re-read the existing setting for every session acquisition and catalog probe. */ +export function resolveStructuredAgentCommand( + agent: 'claude' | 'codex', + settings: CommandSettings, + options: CommandOptions = {} +): string { + return resolveOverride(agent, settings, options) ?? resolveCliCommand(agent, options) +} 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 00000000000..58823a40608 --- /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 00000000000..3b60c764268 --- /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 7ecc214dee7..61dfd787920 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 @@ -81,10 +81,14 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { }) }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // Command values never change the selected chat surface. it.each([ ['claude', 'claude-wrapper'], - ['codex', 'codex-nightly'] + ['codex', 'codex-nightly'], + ['claude', 'npx claude'], + ['codex', 'wrapper --arg'], + ['claude', '/missing/claude'], + ['codex', './codex'] ] as const)('supports %s when this host sets launch command %s', (agent, command) => { const settings = { ...HOST_SELECTED, agentCmdOverrides: { [agent]: command } } expect(support({ agent, getSettings: () => settings })).toEqual({ supported: true }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index b297404a6f4..ad2818c2e9b 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, claudeThinkingDisplay: { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index f299e17b82e..4c0d22560bd 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' import { claudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { @@ -151,6 +153,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: () => @@ -162,7 +170,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 00000000000..e9738615b5f --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +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?.()).toBe(resolveCliCommand('claude')) + settings.agentCmdOverrides = { codex: '/missing/codex' } + expect(deps?.resolveCodexCommand?.()).toBe(resolveCliCommand('codex')) + }) +}) 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 00000000000..6037c26e573 --- /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-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index bfe4e218893..5347acb89e1 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -85,6 +85,7 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveCodexEnvironment, + resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), ...(deps.resolveCodexPermissionPolicy ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } : {}), @@ -118,6 +119,7 @@ function createClaudeAdapter( ...(deps.claudeThinkingDisplay ? { claudeThinkingDisplay: deps.claudeThinkingDisplay } : {}), ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv, + resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs('claude'), resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } 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 9c592779757..e28f7f68900 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 @@ -216,6 +216,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 47a8c609db2..13b70638b95 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 712e0555221..c268c7c8e85 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 239ff65f11c..9825a2b7aee 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -307,6 +307,7 @@ describe('installing the runtime', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) } as unknown as Parameters[0] ) @@ -323,6 +324,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 0bf61aa7843..aa3cf51f132 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 e8296141b3b..50c22f15b7d 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 c00e9c6890f..c5fcba57991 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 5ff7cb3029f..92510aa3241 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 5c2256ad882..8495fb49f5d 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 706b4de5bce..d9562e2ffef 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 eb0bb48c8a0..30a0c423bce 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 6913d3abc7c..b9171cb571b 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) @@ -284,12 +291,6 @@ async function installOnJournal( probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), resolveWorkspacePath: deps.resolveWorkspacePath, - ...(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-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 16cb94a812f..7ebc52cd89a 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 43d457790e3..ec800315ed6 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 c01c30b6b89..bed708ae29a 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 6fadd54a13b..fc5928df20b 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 eaf5644164f..9e9eddb7f85 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 52649785c59..4362c12fec7 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -117,6 +117,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 cb25f94db7c..4212b9788b8 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -24,6 +24,7 @@ import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-dis export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise + resolveClaudeLaunchArgs: () => Promise | string[] resolveClaudeCommand?: () => string /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ claudeThinkingDisplay?: ClaudeThinkingDisplaySupport @@ -87,6 +88,7 @@ export function createStructuredClaudeRuntimeAdapter( resolveLaunch: createClaudeStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, + 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 7a69cbe9c86..b379afbe1e2 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 5e0cf36f14d..3bf2dbf7d19 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 81e7d3f1d8c..52929392d88 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', diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx index 13d4c5fc9d5..6ed8fab450e 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}