From 4e4d990ecf2032d94413fd66cdce773f546a1de2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:34:18 -0700 Subject: [PATCH 1/7] fix(native-chat): honor custom Claude and Codex launch commands --- .../agent-launch/agent-launch-executor.ts | 3 +- src/main/agent-launch/agent-launch-mode.ts | 10 +- .../claude-agent-sdk-contract-pins.test.ts | 35 ++++ .../claude-agent-sdk-process-spawn.test.ts | 44 +++++ .../claude/claude-agent-sdk-process-spawn.ts | 16 +- .../claude/claude-model-catalog-probe.test.ts | 39 ++++ src/main/claude/claude-model-catalog-probe.ts | 38 +++- .../claude-stream-json-connection.test.ts | 25 +++ .../claude/claude-stream-json-connection.ts | 12 +- ...laude-structured-launch-resolution.test.ts | 66 ++++++- .../claude-structured-launch-resolution.ts | 97 +++++++--- ...ed-session-acquisition-processless.test.ts | 18 +- .../claude-structured-session-acquisition.ts | 8 +- .../claude-structured-session-adapter.ts | 9 +- src/main/codex-cli/command.test.ts | 18 ++ .../codex/codex-app-server-session.test.ts | 30 ++++ src/main/codex/codex-app-server-session.ts | 3 + .../codex/codex-model-catalog-probe.test.ts | 31 ++++ src/main/codex/codex-model-catalog-probe.ts | 11 +- .../codex/codex-structured-acquire-catalog.ts | 9 +- ...codex-structured-launch-resolution.test.ts | 67 ++++++- .../codex-structured-launch-resolution.ts | 49 +++-- .../codex/codex-structured-session-adapter.ts | 2 + .../codex/codex-structured-session-state.ts | 2 + .../agent-model-catalog-fingerprint.ts | 31 +++- .../agent-model-catalog-service.test.ts | 167 ++++++++++++++++- .../agent-model-catalog-service.ts | 58 +++++- .../agent-model-catalog-store.ts | 2 + .../structured-agent-session-adapter.ts | 6 +- ...uctured-agent-session-failure-text.test.ts | 8 + .../structured-agent-session-failure-text.ts | 2 + ...ent-session-pre-spawn-first-answer.test.ts | 12 ++ ...agent-session-start-failure-writer.test.ts | 22 +++ ...tructured-agent-command-resolution.test.ts | 88 +++++++++ .../structured-agent-command-resolution.ts | 81 +++++++++ ...tured-agent-session-create-support.test.ts | 4 +- ...structured-agent-session-create-support.ts | 18 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 + .../orchestration-worker-start-mode.test.ts | 4 +- ...ation-worker-start-receipt-wording.test.ts | 9 +- .../structured-agent-model-catalog-wiring.ts | 60 +++++-- .../structured-agent-session-runtime.ts | 13 +- .../structured-claude-runtime-adapter.ts | 5 +- ...veChatLaunchRetry.command-failure.test.tsx | 68 +++++++ .../agent-session-failure-words-text.ts | 11 ++ .../native-chat-session-option-discovery.ts | 15 +- ...ive-chat-session-option-enrichment.test.ts | 14 +- src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + .../src/lib/agent-launch-route-input.test.ts | 38 ++-- .../src/lib/agent-launch-route-input.ts | 27 ++- .../src/lib/agent-launch-routing.test.ts | 15 +- src/renderer/src/lib/agent-launch-routing.ts | 2 - src/shared/agent-launch-intent.ts | 4 +- src/shared/agent-session-failure-copy.ts | 4 + src/shared/agent-session-failure-words.ts | 3 + src/shared/agent-session-failure.ts | 2 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 2 + src/shared/node-cli-command-resolution.ts | 4 +- ...ctured-agent-session-dispatch-rejection.ts | 4 + ...tructured-native-chat-launch-route.test.ts | 6 +- .../structured-native-chat-launch-route.ts | 5 +- .../tui-agent-launch-command-override.test.ts | 106 +++++++++++ .../tui-agent-launch-command-override.ts | 168 ++++++++++++++++-- ...completed-worker-retirement-resume.spec.ts | 1 + tests/e2e/helpers/golden-stub-agent.ts | 1 + ...y-worker-missing-terminal-recovery.spec.ts | 1 + ...ion-legacy-worker-restart-recovery.spec.ts | 1 + ...tion-worker-settlement-release-cli.spec.ts | 1 + ...tration-worker-terminal-visibility.spec.ts | 1 + ...ration-worker-transcript-providers.spec.ts | 1 + ...ettled-worker-tab-survives-restart.spec.ts | 1 + ...native-chat-routing-authority.unit.test.ts | 4 +- 78 files changed, 1534 insertions(+), 224 deletions(-) create mode 100644 src/main/native-chat/structured-agent-command-resolution.test.ts create mode 100644 src/main/native-chat/structured-agent-command-resolution.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx create mode 100644 src/shared/tui-agent-launch-command-override.test.ts diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index b33548abee03..f09179307fe6 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -296,8 +296,7 @@ async function createSurface( * anyway has to say so. * * Reported rather than routed around: the arguments field is a TUI concern by an explicit decision - * (`hasExplicitTuiLaunchCommand` reads the launch command and pointedly not the args, because the - * Agent SDK and app-server version their option sets independently of the interactive CLI), so + * (the Agent SDK and app-server version their option sets independently of the interactive CLI), so * downgrading here would override a stated user preference on the strength of a field that is not * evidence about the surface. `null` warns too: "no arguments" is also unapplied, and the structured * path still reads the bypass-permissions bit out of the user's *settings* default, so a caller that diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 0ef01c01da06..339f9d16c28c 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -32,7 +32,6 @@ import { type StructuredNativeChatBlocker } from '../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../shared/tui-agent' -import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' import type { WorkspaceLaunchKind } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -87,7 +86,7 @@ const DOWNGRADE_DETAIL: Record, s remote_execution_host: 'this launch runs on a remote execution host', reused_terminal: 'it reuses a running terminal agent', agent_without_structured_session: 'this agent has no structured session', - tui_launch_command: 'this agent has a custom launch command that only a terminal runs', + tui_launch_command: 'this launch starts outside the workspace root', structured_sessions_unavailable: 'this runtime does not support structured agent sessions', structured_support_unknown: 'the execution host has not established structured session support', wsl_execution_runtime: 'this workspace runs under WSL', @@ -158,9 +157,10 @@ export function decideAgentLaunchMode(args: { ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), // Mirrors the renderer's own route input (`agent-launch-route-input.ts`): a cwd is terminal-only // when it names somewhere other than the workspace root, by the same shared rule. - requiresTuiLaunchCommand: - requestsCwdOutsideWorkspaceRoot(placement.workspacePath, placement.cwd) || - hasExplicitTuiLaunchCommand(settings, agent) + requiresTuiLaunchCommand: requestsCwdOutsideWorkspaceRoot( + placement.workspacePath, + placement.cwd + ) }) if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index b08bf307734f..62da672983c0 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -16,6 +16,7 @@ import { spawnProcess } from '../../shared/child-process/run-process' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' @@ -518,3 +519,37 @@ describe('Claude Agent SDK contract pins', () => { } }) }) + +it('pins the SDK script-path insertion and runs Orca-owned prefix args exactly once', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const sdkCalls: SdkSpawnOptions[] = [] + const spawner = createClaudeCodeProcessSpawn(spawnProcess, 'win32', { + command: process.execPath, + prefixArgs: [FAKE_CLI] + }) + await drainQuery({ + pathToClaudeCodeExecutable: process.execPath, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + spawnClaudeCodeProcess: (options) => { + sdkCalls.push(options) + return spawner.spawn(options) + } + }) + expect(sdkCalls).toHaveLength(1) + expect(sdkCalls[0]?.command).toBe(process.execPath) + expect(sdkCalls[0]?.args[0]).toBe('--output-format') + expect(scenario.readReport().argv.filter((arg) => arg === FAKE_CLI)).toHaveLength(1) + + const scriptScenarioInput = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const scriptSpawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scriptScenarioInput.cwd, + env: scenarioEnv(scriptScenarioInput), + spawnClaudeCodeProcess: recordingSpawner(scriptSpawns) + }) + expect(scriptSpawns[0]?.command).toBe('node') + expect(scriptSpawns[0]?.args[0]).toBe(FAKE_CLI) + expect(scriptSpawns[0]?.args[1]).toBe('--output-format') +}) diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index 1b6c192d433a..14e003fab857 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -201,3 +201,47 @@ describe('claude agent SDK process spawn', () => { expect(resolved.args[0]).toContain('"--setting-sources=user,project,local"') }) }) + +describe('custom Claude invocation through the SDK spawn callback', () => { + it('builds argv from the selected wrapper and the SDK structured arguments', () => { + const process = fakeSpawn() + createClaudeCodeProcessSpawn(process.spawnImpl, 'win32', { + command: '/selected/wrapper', + prefixArgs: ['code', '--profile', 'work'] + }).spawn(sdkOptions({ command: '/selected/wrapper' })) + expect(process.specs[0]).toMatchObject({ + program: '/selected/wrapper', + args: ['code', '--profile', 'work', '--output-format', 'stream-json'] + }) + }) + it('drops only the exact script path inserted by the SDK runtime selection', () => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, 'win32', { + command: '/selected/wrapper.mjs', + prefixArgs: ['code'] + }) + spawn.spawn( + sdkOptions({ + command: '/runtime/node', + args: ['/selected/wrapper.mjs', '--output-format', 'stream-json'] + }) + ) + expect(process.specs[0]).toMatchObject({ + program: '/selected/wrapper.mjs', + args: ['code', '--output-format', 'stream-json'] + }) + }) + it('refuses SDK script-path drift instead of dropping another token', () => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, 'win32', { + command: '/selected/wrapper.mjs', + prefixArgs: ['code'] + }) + expect(() => + spawn.spawn( + sdkOptions({ command: '/runtime/node', args: ['/different/wrapper.mjs', '--verbose'] }) + ) + ).toThrow('SDK executable contract changed') + expect(process.specs).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index bbb4b295aade..aa23df103c0d 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -1,4 +1,5 @@ import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' +import type { StructuredAgentCommandInvocation } from '../../shared/tui-agent-launch-command-override' import { spawnProcess } from '../../shared/child-process/run-process' import { createProviderSpawnSpec } from '../codex/codex-app-server-posix-supervisor' @@ -44,17 +45,26 @@ function definedEnv(env: Record): Record { + let structuredArgs = options.args + if (invocation && options.command !== invocation.command) { + // The SDK inserts the script path when it chooses a JavaScript runtime. + if (structuredArgs[0] !== invocation.command) { + throw new Error('Claude SDK executable contract changed') + } + structuredArgs = structuredArgs.slice(1) + } const spec = createProviderSpawnSpec( { - command: options.command, - args: [...options.args], + command: invocation?.command ?? options.command, + args: [...(invocation?.prefixArgs ?? []), ...structuredArgs], ...(options.cwd === undefined ? {} : { cwd: options.cwd }) }, definedEnv(options.env), diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 6e1bd13e1b58..6c10b36b83cc 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -125,3 +125,42 @@ describe('claude model catalog probe', () => { await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) }) + +it('prepends the prepared wrapper arguments and uses the chat workspace', async () => { + const deps = probeDeps() + const invocation = { + command: '/chosen/wrapper', + prefixArgs: ['code', '--profile', 'work'], + env: { PATH: '/chosen/bin' } + } + const spawnAgent = vi.fn() + const resolveCommand = vi.fn(() => '/must-not-resolve-again') + const probe = createClaudeModelCatalogProbe({ + ...deps, + resolveCommand, + invocation, + cwd: '/workspace', + spawnAgent, + discover: async (input) => { + expect(input.options.cwd).toBe('/workspace') + input.spawnAgent({ + binary: 'claude', + args: ['--list'], + env: input.env, + stdinMode: 'ignore', + useCwdForNative: false + }) + return listedResult() + } + }) + await probe('/homes/account-a') + expect(spawnAgent).toHaveBeenCalledWith( + expect.objectContaining({ + binary: invocation.command, + args: ['code', '--profile', 'work', '--list'], + cwd: '/workspace', + useCwdForNative: true + }) + ) + expect(resolveCommand).not.toHaveBeenCalled() +}) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index f3a2646107cf..a1749f960503 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -4,6 +4,7 @@ import { spawnSourceControlAgent } from '../text-generation/source-control-agent import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { resolveClaudeStructuredInvocation, + type ClaudeStructuredInvocation, type ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' import type { @@ -13,9 +14,15 @@ import type { export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, - 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' + | 'resolveCommand' + | 'resolveCommandOverride' + | 'resolveEnv' + | 'resolveInheritedEnv' + | 'resolveAuthPolicy' > & { authSwitchSettleTimeoutMs?: number + invocation?: ClaudeStructuredInvocation + cwd?: string /** Test seams; production runs the one-shot listing child. */ discover?: typeof discoverModelsLocal spawnAgent?: typeof spawnSourceControlAgent @@ -33,20 +40,37 @@ export function createClaudeModelCatalogProbe( return async (accountHomePath: string): Promise => { // Same pin rule as the session spawn: naming the CLI's default dir would move // it off the default Keychain item and list under another identity. - const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => ({ - ...base, - ...claudeConfigDirEnvPatch(accountHomePath, { env: base }) - })) + const invocation = + deps.invocation ?? + (await resolveClaudeStructuredInvocation( + deps, + (base) => ({ + ...base, + ...claudeConfigDirEnvPatch(accountHomePath, { env: base }) + }), + deps.cwd + )) + const { command, prefixArgs } = invocation + const env = { + ...invocation.env, + ...claudeConfigDirEnvPatch(accountHomePath, { env: invocation.env }) + } const result = await (deps.discover ?? discoverModelsLocal)({ agentId: 'claude', env, - options: {}, + options: { cwd: deps.cwd }, backslash: commandBackslashMode({ kind: 'local', cwd: '' }), // The resolved absolute command replaces the plan's bare binary directly: // routing it through the command-override template would re-tokenize a // path that may contain spaces. spawnAgent: (input) => - (deps.spawnAgent ?? spawnSourceControlAgent)({ ...input, binary: command }) + (deps.spawnAgent ?? spawnSourceControlAgent)({ + ...input, + cwd: deps.cwd, + useCwdForNative: deps.cwd !== undefined, + binary: command, + args: [...prefixArgs, ...input.args] + }) }) // The spec's static fallback must never pass as a listing: Claude's real // list replaces the seed, so only a probe-origin answer is a catalog. diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 082ed4c12ce7..51207ae8a47c 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -833,3 +833,28 @@ describe('the managed-auth live gate', () => { } }, 30_000) }) + +it('runs a custom interpreter wrapper with prefix args through the real SDK connection', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open({ + ...launchFor(scenario), + pathToClaudeCodeExecutable: process.execPath, + invocation: { + command: process.execPath, + prefixArgs: [FAKE_CLI, '--profile', 'work'], + env: scenario.env, + customCommand: true + } + }) + await connection.initializationResult() + const report = await until(() => readReportSafely(scenario), 'the wrapper report') + expect(launchedArgv(spawned[0]).slice(0, 4)).toEqual([ + process.execPath, + FAKE_CLI, + '--profile', + 'work' + ]) + expect(report.argv.filter((arg) => arg === FAKE_CLI)).toHaveLength(1) + expect(report.argv).toContain('--input-format') + expect(report.argv).toContain(`--session-id=${SESSION_ID}`) +}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 6f67e04b36ea..57e051d72cd0 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -21,7 +21,10 @@ import { claudeUnwrittenUserMessageError, createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' -import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' +import type { + ClaudeStructuredInvocation, + ClaudeStructuredSdkOptions +} from './claude-structured-launch-resolution' export { ClaudeControlRequestError } @@ -47,6 +50,7 @@ function isRecord(value: unknown): value is Record { export type ClaudeStreamJsonLaunch = { /** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */ pathToClaudeCodeExecutable: string + invocation?: ClaudeStructuredInvocation options: ClaudeStructuredSdkOptions cwd: string env?: Record @@ -119,7 +123,11 @@ export async function openClaudeStreamJsonConnection( queryImpl?: typeof ClaudeAgentSdk.query ): Promise { const { query } = await loadClaudeAgentSdk() - const spawner = createClaudeCodeProcessSpawn(spawnImpl) + const spawner = createClaudeCodeProcessSpawn( + spawnImpl, + process.platform, + launch.invocation?.customCommand ? launch.invocation : undefined + ) const inbox = createClaudeUserMessageQueue() const session = (queryImpl ?? query)({ prompt: inbox.messages, diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 66fed64c3230..3205fbfb751d 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -13,7 +13,8 @@ import { CLAUDE_SESSION_STATE_EVENTS_ENV, CLAUDE_STRUCTURED_BASE_OPTIONS, claudeSessionIdForOrcaSession, - createClaudeStructuredLaunchResolver + createClaudeStructuredLaunchResolver, + type ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' @@ -59,7 +60,8 @@ function resolverFor( stripAuthEnv = false, // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, - hasTranscript: () => Promise = async () => true + hasTranscript: () => Promise = async () => true, + commandDeps: Partial = {} ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, @@ -68,7 +70,8 @@ function resolverFor( resolveAuthPolicy: () => ({ stripAuthEnv }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, - ...(resolveEnv ? { resolveEnv } : {}) + ...(resolveEnv ? { resolveEnv } : {}), + ...commandDeps }) } @@ -525,3 +528,60 @@ describe('claude structured launch resolution', () => { }) }) }) + +describe('Claude custom Command setting', () => { + it('rereads the command per acquisition while keeping account and resume controls', async () => { + let command = `"${process.execPath}" wrapper.js --profile work` + const resolver = resolverFor(record(), undefined, false, { claude: '' }, async () => true, { + resolveCommandOverride: () => command + }) + const first = await resolver({ identity: IDENTITY }) + expect(first.pathToClaudeCodeExecutable).toBe(process.execPath) + expect(first.invocation?.prefixArgs).toEqual(['wrapper.js', '--profile', 'work']) + expect(first.claudeConfigDir).toBe('/home/work/.claude') + command = `"${process.execPath}" another-wrapper.js` + const second = await resolver({ identity: IDENTITY }) + expect(second.invocation?.prefixArgs).toEqual(['another-wrapper.js']) + expect(second.providerSessionId).toBe(first.providerSessionId) + }) + it.each([ + { command: 'missing-custom-claude', options: {}, reason: 'customCommandInvalid' }, + { + command: `"${process.execPath}" --model sonnet`, + options: { model: 'sonnet' }, + reason: 'customCommandConflict' + }, + { + command: `"${process.execPath}" --effort high`, + options: { effort: 'high' }, + reason: 'customCommandConflict' + }, + { command: `"${process.execPath}" --resume alien`, options: {}, reason: 'customCommandInvalid' } + ])('refuses $reason before any process starts', async ({ command, options, reason }) => { + const defaultCommand = vi.fn(() => '/must-not-fall-back/claude') + const resolver = resolverFor( + record({ + options: { + ...(options.model !== undefined ? { model: options.model } : {}), + ...(options.effort !== undefined ? { effort: options.effort } : {}) + } + }), + undefined, + false, + { claude: '' }, + async () => true, + { + resolveCommand: defaultCommand, + resolveCommandOverride: () => command + } + ) + await expect(resolver({ identity: IDENTITY })).rejects.toMatchObject({ reason }) + expect(defaultCommand).not.toHaveBeenCalled() + }) + it('allows an override default model when the chat has no model preference', async () => { + const launch = await resolverFor(record(), undefined, false, { claude: '' }, async () => true, { + resolveCommandOverride: () => `"${process.execPath}" --model sonnet` + })({ identity: IDENTITY }) + expect(launch.invocation?.prefixArgs).toEqual(['--model', 'sonnet']) + }) +}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 29703dfd8eb7..042f10e8655b 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -26,6 +26,11 @@ import { structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + resolveStructuredAgentCommand, + assertStructuredAgentCommandPreferences +} from '../native-chat/structured-agent-command-resolution' +import type { StructuredAgentCommandInvocation } from '../../shared/tui-agent-launch-command-override' import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' @@ -91,6 +96,7 @@ export function claudeStructuredPermissionOptions( export type ClaudeStructuredLaunch = { /** Always Orca's resolved user CLI: the SDK's bundled binaries are excluded from the install. */ pathToClaudeCodeExecutable: string + invocation?: ClaudeStructuredInvocation options: ClaudeStructuredSdkOptions cwd: string env?: Record @@ -109,6 +115,7 @@ export type ClaudeStructuredLaunchResolverDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string + resolveCommandOverride?: () => string | null | undefined resolveEnv?: () => | Promise | undefined> | Record @@ -145,7 +152,10 @@ async function claudeTranscriptExists(input: { return path !== null } -export type ClaudeStructuredInvocation = { command: string; env: Record } +export type ClaudeStructuredInvocation = StructuredAgentCommandInvocation & { + env: Record + customCommand?: true +} /** * The one place a structured Claude child's binary and environment are @@ -157,11 +167,15 @@ export type ClaudeStructuredInvocation = { command: string; env: Record & { authSwitchSettleTimeoutMs?: number }, - decorateEnv: (env: Record) => Record = (env) => env + decorateEnv: (env: Record) => Record = (env) => env, + cwd?: string ): Promise { - const command = (deps.resolveCommand ?? resolveClaudeCommand)() const auth = await deps.resolveAuthPolicy() const overlay = await deps.resolveEnv?.() const inheritedEnv = deps.resolveInheritedEnv @@ -182,22 +196,41 @@ export async function resolveClaudeStructuredInvocation( // derives PATH from what it is handed. Ambient Anthropic auth is stripped from the // inherited half only when a managed account owns the credential; a system-auth // user's own key is their sign-in and must reach the child. - const env = withCliRuntimeOnPath( + const baseEnv = decorateEnv({ + ...applyClaudeEnvPatch( + withoutInheritedClaudeConfigDir(inheritedEnv, process.platform), + {}, + { + stripAuthEnv: auth.stripAuthEnv, + platform: process.platform + } + ), + ...(overlay ? cloneDefinedEnv(overlay) : {}) + }) + const selected = resolveStructuredAgentCommand({ + agent: 'claude', + override: deps.resolveCommandOverride?.(), + env: baseEnv, + cwd + }) + const command = + selected?.command ?? + ( + deps.resolveCommand ?? + (() => + resolveClaudeCommand({ + pathEnv: baseEnv.PATH ?? baseEnv.Path, + homePath: baseEnv.HOME ?? baseEnv.USERPROFILE + })) + )() + const env = withCliRuntimeOnPath(command, baseEnv, { platform: process.platform }) + return { + ...selected, command, - decorateEnv({ - ...applyClaudeEnvPatch( - withoutInheritedClaudeConfigDir(inheritedEnv, process.platform), - {}, - { - stripAuthEnv: auth.stripAuthEnv, - platform: process.platform - } - ), - ...(overlay ? cloneDefinedEnv(overlay) : {}) - }), - { platform: process.platform } - ) - return { command, env } + prefixArgs: selected?.prefixArgs ?? [], + env, + ...(selected ? { customCommand: true } : {}) + } } /** @@ -287,16 +320,22 @@ export function createClaudeStructuredLaunchResolver( const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) - const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => - // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. - structuredSessionChildIdentityEnv(record.sessionId, { - ...base, - // The turn translator relies on Claude's authoritative idle frame when no result arrives. - [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' - }) + const cwd = await deps.resolveWorkspacePath(record.location.workspaceId) + const invocation = await resolveClaudeStructuredInvocation( + deps, + (base) => + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + structuredSessionChildIdentityEnv(record.sessionId, { + ...base, + // The turn translator relies on Claude's authoritative idle frame when no result arrives. + [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' + }), + cwd ) + assertStructuredAgentCommandPreferences('claude', invocation, record.options ?? {}) return { - pathToClaudeCodeExecutable: command, + pathToClaudeCodeExecutable: invocation.command, + invocation, options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, @@ -304,8 +343,8 @@ export function createClaudeStructuredLaunchResolver( // Claude owns where a resumed conversation continues; the stored leaf is Orca's bookkeeping. ...(resumesTranscript ? { resume: providerSessionId } : { sessionId: providerSessionId }) }, - cwd: await deps.resolveWorkspacePath(record.location.workspaceId), - env, + cwd, + env: invocation.env, claudeConfigDir: record.accountHome.path, providerSessionId, resumeLeafUuid: diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index eb4babdb9aca..db460f8d980b 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -20,10 +20,12 @@ describe('Claude structured processless acquisition', () => { it('classifies pre-pid error and close as processless with idempotent cleanup', async () => { const fault = new Error('spawn claude ENOENT') const close = vi.fn(async () => true) - const openConnection: typeof openClaudeStreamJsonConnection = async ( - _launch, - handlers = {} - ) => { + const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { + expect(launch.invocation).toMatchObject({ + command: '/fake/wrapper', + prefixArgs: ['code'], + customCommand: true + }) // A failed spawn reports its error on a later tick, as child_process does. setTimeout(() => handlers.onFault?.(fault), 0) const connection: ClaudeStreamJsonConnection = { @@ -49,7 +51,13 @@ describe('Claude structured processless acquisition', () => { } const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', + pathToClaudeCodeExecutable: '/fake/wrapper', + invocation: { + command: '/fake/wrapper', + prefixArgs: ['code'], + env: {}, + customCommand: true + }, options: {}, cwd: '/work/repo', claudeConfigDir: '/accounts/claude', diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 404787620e95..da9798da6619 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -149,9 +149,7 @@ export async function acquireClaudeSession({ ...observedAt }) ) - for (const settle of settlements) { - settle() - } + settlements.forEach((settle) => settle()) } const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, @@ -178,6 +176,7 @@ export async function acquireClaudeSession({ open( { pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, + ...(launch.invocation ? { invocation: launch.invocation } : {}), options: launch.options, cwd: launch.cwd, env: { @@ -260,7 +259,8 @@ export async function acquireClaudeSession({ const catalogAccess = agentModelCatalogSessionAccess( deps.modelCatalog, 'claude', - launch.claudeConfigDir + launch.claudeConfigDir, + launch.invocation ) if (catalogAccess) { session.catalogAccess = catalogAccess diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 7dae79b408f0..7a295858b218 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -45,11 +45,8 @@ import { import { claudePromptCancelRoute } from './claude-structured-prompt-replies' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' -export type { - ClaudeAuthDiagnostic, - ClaudeStructuredSessionAdapterDeps, - ClaudeStructuredSessionEvent -} from './claude-structured-session-state' +export type { ClaudeAuthDiagnostic } from './claude-structured-session-state' +export type { ClaudeStructuredSessionAdapterDeps, ClaudeStructuredSessionEvent } function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null { const state = session.backgroundTasks.state @@ -275,6 +272,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(sessionId), this.deps.requestTimeoutMs ?? CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS ) + readCatalogAccess = (sessionId: string) => this.sessions.get(sessionId)?.catalogAccess + readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) // Provider-level: a session at rest still reports the usage its journal recorded. diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index dbe1c358418a..dafa470f3704 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -390,3 +390,21 @@ describe('withCliRuntimeOnPath', () => { expect(paired.Path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';')) }) }) + +describe('explicit Windows executable suffixes', () => { + it.each(['cmd', 'exe', 'bat', 'com'])( + 'resolves a bare wrapper.%s without appending a second suffix', + (suffix) => { + const root = mkdtempSync(join(tmpdir(), 'orca-custom-command-')) + const binary = join(root, `wrapper.${suffix}`) + makeExecutable(binary) + expect( + resolveCliCommands([`wrapper.${suffix}`], { + platform: 'win32', + pathEnv: root, + homePath: root + }).get(`wrapper.${suffix}`) + ).toBe(binary) + } + ) +}) diff --git a/src/main/codex/codex-app-server-session.test.ts b/src/main/codex/codex-app-server-session.test.ts index a48ed025457f..3ad45fab3f19 100644 --- a/src/main/codex/codex-app-server-session.test.ts +++ b/src/main/codex/codex-app-server-session.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { runCodexAppServerSession } from './codex-app-server-session' @@ -70,3 +73,30 @@ describe('runCodexAppServerSession environment', () => { expect(result).toEqual({ largeBytes: 1024 * 1024 + 1, followup: { alive: true } }) }) }) + +it('runs a catalog wrapper in the chosen workspace directory', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-catalog-cwd-')) + const server = String.raw` + const readline = require('node:readline') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (typeof message.id !== 'number') return + process.stdout.write(JSON.stringify({ id: message.id, result: { cwd: process.cwd() } }) + '\n') + }) + ` + try { + const result = await runCodexAppServerSession( + { + command: process.execPath, + cliPath: null, + args: ['-e', server], + cwd: root, + timeoutMs: 5_000 + }, + ({ request }) => request('cwd/get') + ) + expect(result).toEqual({ cwd: realpathSync(root) }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 389cd20516cb..33fb1364708b 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -16,6 +16,7 @@ import { createCodexAppServerRecordReader } from './codex-app-server-record-read export type CodexAppServerInvocation = { command: string + cwd?: string args: string[] /** * The resolved CLI path, used to pair the CLI with the `node` it was installed @@ -109,8 +110,10 @@ export async function runCodexAppServerSession( const pairedEnv = invocation.cliPath ? withCliRuntimeOnPath(invocation.cliPath, childEnv) : childEnv + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the requested three pipe streams are present on the spawned process. const child = spawnImpl(invocation.command, invocation.args, { env: pairedEnv, + ...(invocation.cwd ? { cwd: invocation.cwd } : {}), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) as ChildProcessWithoutNullStreams diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index 87c615d410bb..702bfe132862 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -102,3 +102,34 @@ describe('codex model catalog probe', () => { await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) }) + +it('prepends the prepared wrapper arguments and uses the chat workspace', async () => { + const invocation = { + command: '/chosen/wrapper', + prefixArgs: ['--profile', 'work'], + environment: { PATH: '/chosen/bin' } + } + const resolveCommand = vi.fn(() => '/must-not-resolve-again') + const seen: CodexAppServerInvocation[] = [] + const probe = createCodexModelCatalogProbe({ + invocation, + cwd: '/workspace', + resolveCommand, + runSession: async (input, body) => { + seen.push(input) + return body({ + request: async () => ({ data: [MODEL_ROW], nextCursor: null }), + notify: () => {} + }) + } + }) + await probe('/homes/account-a') + expect(seen[0]).toMatchObject({ + command: invocation.command, + cwd: '/workspace', + env: { CODEX_HOME: '/homes/account-a' } + }) + expect(seen[0]?.args.slice(0, 2)).toEqual(['--profile', 'work']) + expect(seen[0]?.args.filter((arg) => arg === 'app-server')).toHaveLength(1) + expect(resolveCommand).not.toHaveBeenCalled() +}) diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 2cf354259d74..87cd7a06ce42 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -3,6 +3,7 @@ import { runCodexAppServerSession } from './codex-app-server-session' import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' import { resolveCodexStructuredInvocation, + type CodexStructuredInvocation, type CodexStructuredLaunchResolverDeps } from './codex-structured-launch-resolution' import type { @@ -16,10 +17,12 @@ const CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS = 15_000 export type CodexModelCatalogProbeDeps = Pick< CodexStructuredLaunchResolverDeps, - 'resolveCommand' | 'resolveEnvironment' + 'resolveCommand' | 'resolveEnvironment' | 'resolveCommandOverride' > & { /** Test seam; production runs the shared short-lived app-server session. */ runSession?: typeof runCodexAppServerSession + invocation?: CodexStructuredInvocation + cwd?: string } function definedEnv(env: NodeJS.ProcessEnv | undefined): Record { @@ -42,12 +45,14 @@ export function createCodexModelCatalogProbe( deps: CodexModelCatalogProbeDeps ): AgentModelCatalogProbe { return async (accountHomePath: string): Promise => { - const { command, environment } = await resolveCodexStructuredInvocation(deps) + const { command, prefixArgs, environment } = + deps.invocation ?? (await resolveCodexStructuredInvocation(deps, deps.cwd)) const run = deps.runSession ?? runCodexAppServerSession const listing = await run( { command, - args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], + ...(deps.cwd ? { cwd: deps.cwd } : {}), + args: [...prefixArgs, ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: command, env: { ...definedEnv(environment), CODEX_HOME: accountHomePath }, timeoutMs: CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS diff --git a/src/main/codex/codex-structured-acquire-catalog.ts b/src/main/codex/codex-structured-acquire-catalog.ts index 6a420f2dd990..0a97aed0512e 100644 --- a/src/main/codex/codex-structured-acquire-catalog.ts +++ b/src/main/codex/codex-structured-acquire-catalog.ts @@ -14,9 +14,14 @@ import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catal export function codexAcquireCatalogAccess( deps: Pick, - launch: Pick + launch: Pick ): CodexSessionCatalogAccess | undefined { - return agentModelCatalogSessionAccess(deps.modelCatalog, 'codex', launch.codexHome) + return agentModelCatalogSessionAccess( + deps.modelCatalog, + 'codex', + launch.codexHome, + launch.invocation + ) } /** The one catalog read a fast-mode restore needs, store-first. Null degrades diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 36ab39e3babc..5cc07be557fa 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -3,7 +3,10 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' +import { + createCodexStructuredLaunchResolver, + type CodexStructuredLaunchResolverDeps +} from './codex-structured-launch-resolution' import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' const SESSION_ID = 'session-1' @@ -41,7 +44,8 @@ function resolverFor( value: AgentSessionRecord | null, resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, resolveRollout: () => Promise = async () => null, - agentDefaultArgs: Record = { codex: '' } + agentDefaultArgs: Record = { codex: '' }, + commandDeps: Partial = {} ) { return createCodexStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, @@ -49,7 +53,8 @@ function resolverFor( resolveCommand: () => '/usr/local/bin/codex', resolveRollout, isWindowsProcessStartTimeAvailable: () => true, - resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs }) + resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs }), + ...commandDeps }) } @@ -59,6 +64,7 @@ describe('codex structured launch resolution', () => { expect(launch).toEqual({ command: '/usr/local/bin/codex', + invocation: { command: '/usr/local/bin/codex', prefixArgs: [] }, args: ['app-server'], cwd: '/repos/workspace-1', codexHome: '/home/work/.codex', @@ -261,3 +267,58 @@ describe('codex structured launch resolution', () => { ).rejects.toThrow('workspace-1 is gone') }) }) + +describe('Codex custom Command setting', () => { + it('prepends wrapper arguments and rereads the host setting per acquisition', async () => { + let command = `"${process.execPath}" wrapper.js --profile work` + const resolver = resolverFor(record(), undefined, undefined, undefined, { + resolveCommandOverride: () => command + }) + const first = await resolver({ identity: IDENTITY }) + expect(first.command).toBe(process.execPath) + expect(first.args).toEqual(['wrapper.js', '--profile', 'work', 'app-server']) + expect(first.codexHome).toBe('/home/work/.codex') + command = `"${process.execPath}" another-wrapper.js` + expect((await resolver({ identity: IDENTITY })).args).toEqual([ + 'another-wrapper.js', + 'app-server' + ]) + }) + it.each([ + { command: 'missing-custom-codex', options: {}, reason: 'customCommandInvalid' }, + { + command: `"${process.execPath}" --model gpt-live`, + options: { model: 'gpt-live' }, + reason: 'customCommandConflict' + }, + { + command: `"${process.execPath}" -c model_reasoning_effort=high`, + options: { effort: 'high' }, + reason: 'customCommandConflict' + }, + { + command: `"${process.execPath}" --listen http://localhost`, + options: {}, + reason: 'customCommandInvalid' + } + ])('refuses $reason before any process starts', async ({ command, options, reason }) => { + const defaultCommand = vi.fn(() => '/must-not-fall-back/codex') + const resolver = resolverFor( + record({ + options: { + ...(options.model !== undefined ? { model: options.model } : {}), + ...(options.effort !== undefined ? { effort: options.effort } : {}) + } + }), + undefined, + undefined, + undefined, + { + resolveCommand: defaultCommand, + resolveCommandOverride: () => command + } + ) + await expect(resolver({ identity: IDENTITY })).rejects.toMatchObject({ reason }) + expect(defaultCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index ebfdb27200a5..97eed67281c6 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -1,7 +1,7 @@ // How a durable session record becomes a Codex process launch. // -// Every input is read back from the record the store already made durable, not -// from the call that triggered the acquire. A client that attaches twice must +// Session identity is read back from the durable record; the host's launch settings +// are re-read per acquisition. A client that attaches twice must // land in the same working directory under the same account home, and a resume // must name the thread this session actually proved — never one a caller asks // for, which is how a resume becomes a fork wearing a resume's name. @@ -9,6 +9,11 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + resolveStructuredAgentCommand, + assertStructuredAgentCommandPreferences +} from '../native-chat/structured-agent-command-resolution' +import type { StructuredAgentCommandInvocation } from '../../shared/tui-agent-launch-command-override' import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' @@ -25,6 +30,7 @@ export type CodexStructuredLaunchResolverDeps = { resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise + resolveCommandOverride?: () => string | null | undefined resolveRollout?: typeof resolvePinnedCodexRolloutProof /** Test seam for the host capability; production uses the native process table. */ isWindowsProcessStartTimeAvailable?: () => boolean @@ -33,8 +39,7 @@ export type CodexStructuredLaunchResolverDeps = { resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy } -export type CodexStructuredInvocation = { - command: string +export type CodexStructuredInvocation = StructuredAgentCommandInvocation & { environment: NodeJS.ProcessEnv | undefined } @@ -46,16 +51,28 @@ export type CodexStructuredInvocation = { * drift there heals on the next refresh. */ export async function resolveCodexStructuredInvocation( - deps: Pick + deps: Pick< + CodexStructuredLaunchResolverDeps, + 'resolveCommand' | 'resolveEnvironment' | 'resolveCommandOverride' + >, + cwd?: string ): Promise { const environment = await deps.resolveEnvironment?.() const pathEnv = environment?.PATH ?? environment?.Path ?? null const homePath = environment?.HOME ?? environment?.USERPROFILE - const command = (deps.resolveCommand ?? resolveCodexCommand)({ - pathEnv, - ...(homePath ? { homePath } : {}) + const selected = resolveStructuredAgentCommand({ + agent: 'codex', + override: deps.resolveCommandOverride?.(), + env: environment ?? process.env, + cwd }) - return { command, environment } + const command = + selected?.command ?? + (deps.resolveCommand ?? resolveCodexCommand)({ + pathEnv, + ...(homePath ? { homePath } : {}) + }) + return { ...selected, command, prefixArgs: selected?.prefixArgs ?? [], environment } } export function createCodexStructuredLaunchResolver( @@ -88,7 +105,10 @@ export function createCodexStructuredLaunchResolver( if (accountHome.variable !== 'CODEX_HOME') { throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) } - const { command, environment } = await resolveCodexStructuredInvocation(deps) + const cwd = await deps.resolveWorkspacePath(location.workspaceId) + const invocation = await resolveCodexStructuredInvocation(deps, cwd) + const { command, environment } = invocation + assertStructuredAgentCommandPreferences('codex', invocation, record.options ?? {}) // `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 permissionPolicy = deps.resolvePermissionPolicy?.() @@ -98,8 +118,13 @@ export function createCodexStructuredLaunchResolver( const model = record.options?.model return { command, - args: ['app-server'], - cwd: await deps.resolveWorkspacePath(location.workspaceId), + args: [...invocation.prefixArgs, 'app-server'], + invocation: { + command, + prefixArgs: invocation.prefixArgs, + ...(invocation.cwd ? { cwd: invocation.cwd } : {}) + }, + cwd, codexHome: accountHome.path, ...(environment ? { env: { ...environment } as Record } : {}), // An empty chain is a session that has never proved a thread, so it diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 6f67b68f8e63..cf1af280aa13 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -278,6 +278,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } + readCatalogAccess = (sessionId: string) => this.sessions.get(sessionId)?.catalogAccess + readOptions = (input: { sessionId: string; fence: number }) => readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 6ddc11ac1f60..351109a9e38a 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentCommandInvocation } from '../../shared/tui-agent-launch-command-override' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -28,6 +29,7 @@ import type { export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess export type CodexStructuredLaunch = { + invocation?: StructuredAgentCommandInvocation command: string args: string[] cwd: string diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index 28b1f8729d3c..8e909cf89995 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import type { StructuredAgentCommandInvocation } from '../../../shared/tui-agent-launch-command-override' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentModelCatalogSessionAccess, @@ -7,7 +8,7 @@ import type { /** * Everything that changes which models a listing can answer with: the agent, - * the account home the CLI reads credentials/config from, and the execution + * the account home the CLI reads credentials/config from, its command and leading arguments, and the execution * host that runs the binary. Login-state or CLI-version drift under the same * key is corrected by the next refresh, never by the fingerprint. */ @@ -17,6 +18,7 @@ export type AgentModelCatalogIdentity = { accountHomePath: string /** Null on the native host; WSL distros each carry their own CLI. */ wslDistro: string | null + invocation?: StructuredAgentCommandInvocation } export function agentModelCatalogFingerprint(identity: AgentModelCatalogIdentity): string { @@ -26,7 +28,14 @@ export function agentModelCatalogFingerprint(identity: AgentModelCatalogIdentity identity.agent, identity.accountHomeVariable, identity.accountHomePath, - identity.wslDistro ?? '' + identity.wslDistro ?? '', + ...(identity.invocation + ? [ + identity.invocation.command, + identity.invocation.prefixArgs, + ...(identity.invocation.cwd ? [identity.invocation.cwd] : []) + ] + : []) ]) ) .digest('hex') @@ -51,24 +60,34 @@ export function agentModelCatalogFingerprintForRecord( return agentModelCatalogFingerprint(agentModelCatalogIdentityForRecord(record)) } -/** A live session's store handle, pinned to the account home it spawned under. +/** A live session's store handle, pinned to its account home and command invocation. * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, agent: 'claude' | 'codex', - accountHomePath: string | null + accountHomePath: string | null, + invocation?: StructuredAgentCommandInvocation ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { return undefined } + const pinned = invocation + ? { + command: invocation.command, + prefixArgs: [...invocation.prefixArgs], + ...(invocation.cwd ? { cwd: invocation.cwd } : {}) + } + : undefined return { store, fingerprint: agentModelCatalogFingerprint({ agent, accountHomeVariable: agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', accountHomePath, - wslDistro: null + wslDistro: null, + ...(pinned ? { invocation: pinned } : {}) }), - accountHomePath + accountHomePath, + ...(pinned ? { invocation: pinned } : {}) } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index 020254851513..07cf3ec65bc8 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentModelCatalogFingerprint, - agentModelCatalogFingerprintForRecord + agentModelCatalogFingerprintForRecord, + agentModelCatalogSessionAccess } from './agent-model-catalog-fingerprint' -import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + createAgentModelCatalogService, + type AgentModelCatalogServiceDeps +} from './agent-model-catalog-service' import { AGENT_MODEL_CATALOG_FRESH_MS, AgentModelCatalogStore, @@ -312,3 +316,162 @@ describe('agent model catalog service', () => { }) }) }) + +describe('catalog identity of the selected command', () => { + it('keeps legacy cached listings and each command/prefix pair separate', async () => { + const store = new AgentModelCatalogStore() + store.recordSuccess(selectedHomeFingerprint('/homes/a'), 'codex', listing('legacy')) + let command = '/commands/first' + let prefixArgs = ['--profile', 'work'] + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + resolveAccountHome: async () => CODEX_HOME('/homes/a'), + prepareProbe: async () => { + const invocation = { command, prefixArgs: [...prefixArgs] } + return { + invocation, + probe: async () => listing(`${invocation.command}:${invocation.prefixArgs.join(' ')}`) + } + } + }) + const first = await service.read({ agent: 'codex', waitForListing: true }) + expect(first.origin === 'unknown' ? null : first.models[0]?.id).toBe( + '/commands/first:--profile work' + ) + command = '/commands/second' + const second = await service.read({ agent: 'codex', waitForListing: true }) + expect(second.origin === 'unknown' ? null : second.models[0]?.id).toBe( + '/commands/second:--profile work' + ) + prefixArgs = ['--profile', 'personal'] + const third = await service.read({ agent: 'codex', waitForListing: true }) + expect(third.origin === 'unknown' ? null : third.models[0]?.id).toBe( + '/commands/second:--profile personal' + ) + expect(store.get(selectedHomeFingerprint('/homes/a'))?.models[0]?.id).toBe('legacy') + }) + + it('pins an in-flight probe to the invocation that named its cache key', async () => { + const store = new AgentModelCatalogStore() + let command = '/commands/first' + let firstStarted = false + let finishFirst: (value: AgentModelCatalogSuccess) => void = () => {} + const firstResult = new Promise((resolve) => { + finishFirst = resolve + }) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + resolveAccountHome: async () => CODEX_HOME('/homes/a'), + prepareProbe: async () => { + const invocation = { command, prefixArgs: [] } + return { + invocation, + probe: async () => { + firstStarted = true + return invocation.command.endsWith('first') ? firstResult : listing('second') + } + } + } + }) + const first = service.read({ agent: 'codex', waitForListing: true }) + await vi.waitFor(() => expect(firstStarted).toBe(true)) + command = '/commands/second' + const second = await service.read({ agent: 'codex', waitForListing: true }) + finishFirst(listing('first')) + const firstListed = await first + expect(firstListed.origin === 'unknown' ? null : firstListed.models[0]?.id).toBe('first') + expect(second.origin === 'unknown' ? null : second.models[0]?.id).toBe('second') + }) + + it('refreshes a live session through its pinned command after the setting changes', async () => { + const store = new AgentModelCatalogStore() + const invocation = { command: '/commands/live', prefixArgs: ['code'] } + const access = agentModelCatalogSessionAccess(store, 'codex', '/homes/a', invocation) + const prepareProbe = vi.fn>( + async (_agent, _cwd, pinned) => ({ + invocation: pinned ?? { command: '/commands/new-setting', prefixArgs: [] }, + probe: async () => listing(pinned?.command ?? 'new-setting') + }) + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => record('/homes/a'), + resolveAccountHome: async () => CODEX_HOME('/homes/selected'), + readSessionCatalogAccess: () => access, + resolveWorkspacePath: async () => '/workspace', + prepareProbe + }) + const result = await service.read({ + agent: 'codex', + sessionId: 'session-1', + waitForListing: true + }) + expect(prepareProbe).toHaveBeenCalledWith('codex', '/workspace', invocation) + expect(result.origin === 'unknown' ? null : result.models[0]?.id).toBe('/commands/live') + expect(store.get(access?.fingerprint ?? '')?.models[0]?.id).toBe('/commands/live') + }) + + it('does not serve the default listing when the current command fails resolution', async () => { + const store = new AgentModelCatalogStore() + store.recordSuccess(selectedHomeFingerprint('/homes/a'), 'codex', listing('legacy')) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + resolveAccountHome: async () => CODEX_HOME('/homes/a'), + prepareProbe: async () => { + throw new Error('command missing') + } + }) + expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' }) + }) +}) + +it('keeps a live child’s cached listing when fresh probe environment resolution fails', async () => { + const store = new AgentModelCatalogStore() + const access = agentModelCatalogSessionAccess(store, 'codex', '/homes/a', { + command: '/live/wrapper', + prefixArgs: ['code'] + }) + if (!access) { + throw new Error('missing session catalog') + } + store.recordSuccess(access.fingerprint, 'codex', listing('live')) + const service = createAgentModelCatalogService({ + store, + getRecord: () => record('/homes/a'), + resolveAccountHome: async () => CODEX_HOME('/homes/a'), + readSessionCatalogAccess: () => access, + prepareProbe: async () => { + throw new Error('fresh environment unavailable') + } + }) + const result = await service.read({ agent: 'codex', sessionId: 'session-1' }) + expect(result.origin === 'unknown' ? null : result.models[0]?.id).toBe('live') +}) + +it('isolates identical relative wrapper arguments by their execution directory', async () => { + const store = new AgentModelCatalogStore() + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + resolveAccountHome: async () => CODEX_HOME('/homes/a'), + prepareProbe: async (_agent, cwd) => ({ + invocation: { command: '/runtime/node', prefixArgs: ['./wrapper.js'], cwd }, + probe: async () => listing(cwd ?? 'no-workspace') + }) + }) + const first = await service.read({ + agent: 'codex', + workspacePath: '/workspace/a', + waitForListing: true + }) + const second = await service.read({ + agent: 'codex', + workspacePath: '/workspace/b', + waitForListing: true + }) + expect(first.origin === 'unknown' ? null : first.models[0]?.id).toBe('/workspace/a') + expect(second.origin === 'unknown' ? null : second.models[0]?.id).toBe('/workspace/b') +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 638b0f54272f..b481c5baa27d 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,10 +1,12 @@ import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' +import type { StructuredAgentCommandInvocation } from '../../../shared/tui-agent-launch-command-override' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' import type { + AgentModelCatalogSessionAccess, AgentModelCatalogEntry, AgentModelCatalogProbe, AgentModelCatalogStore @@ -19,6 +21,16 @@ export type AgentModelCatalogServiceDeps = { resolveAccountHome: ( agent: 'claude' | 'codex' ) => Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }> + readSessionCatalogAccess?: ( + agent: 'claude' | 'codex', + sessionId: string + ) => AgentModelCatalogSessionAccess | undefined + resolveWorkspacePath?: (workspaceId: string) => Promise + prepareProbe?: ( + agent: 'claude' | 'codex', + cwd?: string, + invocation?: StructuredAgentCommandInvocation + ) => Promise<{ invocation: StructuredAgentCommandInvocation; probe: AgentModelCatalogProbe }> /** Session-less listers, one per agent that has one on this host. */ probes?: Partial> /** Whether the workspace's own config could pick a model other than the listed default. */ @@ -113,15 +125,55 @@ export function createAgentModelCatalogService( }) accountHomePath = resolved.path } + const active = + scoped && params.sessionId + ? deps.readSessionCatalogAccess?.(params.agent, params.sessionId) + : undefined + let probe = deps.probes?.[params.agent] + if (deps.prepareProbe && accountHomePath) { + try { + const cwd = + active?.invocation?.cwd ?? + (scoped && deps.resolveWorkspacePath + ? await deps.resolveWorkspacePath(scoped.location.workspaceId) + : (params.workspacePath ?? undefined)) + const prepared = await deps.prepareProbe(params.agent, cwd, active?.invocation) + fingerprint = + active?.fingerprint ?? + agentModelCatalogFingerprint({ + agent: params.agent, + accountHomeVariable: + scoped?.accountHome.variable ?? + (params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME'), + accountHomePath, + wslDistro: scoped?.location.wslDistro ?? null, + invocation: prepared.invocation + }) + probe = prepared.probe + } catch { + const cached = active && deps.store.get(active.fingerprint) + return cached + ? resultFromEntry( + cached, + await workspaceKeepsListedDefault( + deps, + params.agent, + params.workspacePath, + accountHomePath + ) + ) + : { origin: 'unknown' } + } + } let entry = deps.store.get(fingerprint) - const probe = deps.probes?.[params.agent] const home = accountHomePath // Without an entry, join a running listing too: that is the one a waiting read answers from. + const selectedProbe = probe const listing = - probe && + selectedProbe && home && (entry ? deps.store.shouldRefresh(fingerprint) : !deps.store.hasActiveFailure(fingerprint)) - ? deps.store.refresh(fingerprint, params.agent, () => probe(home)) + ? deps.store.refresh(fingerprint, params.agent, () => selectedProbe(home)) : null if (!entry) { if (!listing) { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index dcb6e3935ac5..f0ce6e6f9290 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentCommandInvocation } from '../../../shared/tui-agent-launch-command-override' import type { AgentSessionFastModeSupport, AgentSessionModelOption @@ -47,6 +48,7 @@ export type AgentModelCatalogSessionAccess = { store: AgentModelCatalogStore fingerprint: string accountHomePath: string + invocation?: StructuredAgentCommandInvocation } function tierRecord(tiers: ReadonlyMap): Record { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9c34c1841048..8e81490e6092 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -127,7 +127,11 @@ export type AgentSessionAcquisition = { /** A refusal before spawn that a person can act on; the site that refused names it. */ export type AgentSessionPreSpawnReason = Extract< AgentSessionRefusalReason<'agent_session_operation_invalid'>, - 'managedAccountEnvOverride' | 'accountSwitchInProgress' | 'managedAccountUnsupported' + | 'customCommandInvalid' + | 'customCommandConflict' + | 'managedAccountEnvOverride' + | 'accountSwitchInProgress' + | 'managedAccountUnsupported' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index ccef42f57bdd..6295fda2ee54 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 @@ -54,6 +54,14 @@ describe('structuredAgentSessionStartFailure', () => { }) it.each([ + [ + 'customCommandInvalid', + "Claude couldn't start with your custom command. Use a program and its arguments in Settings → Agents → Command, or clear it." + ], + [ + 'customCommandConflict', + 'The custom command conflicts with this chat’s model or effort. Remove model or effort flags in Settings → Agents → Command.' + ], [ 'managedAccountEnvOverride', 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index c0c2172230dc..955fd5efdd38 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 @@ -24,6 +24,8 @@ import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapt const TYPED_START_REFUSALS = [ 'notSignedIn', 'historyTooLarge', + 'customCommandInvalid', + 'customCommandConflict', 'managedAccountEnvOverride', 'accountSwitchInProgress', 'managedAccountUnsupported' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 1e171e64199e..9954d73f0b28 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 @@ -110,6 +110,18 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { describe('a create that fails before any process spawns', () => { it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ + [ + 'a command that cannot be spawned', + 'ENOENT /private/custom-wrapper', + 'customCommandInvalid', + "Claude couldn't start with your custom command. Use a program and its arguments in Settings → Agents → Command, or clear it." + ], + [ + 'a command that conflicts with the chat preferences', + 'custom command singleton flags', + 'customCommandConflict', + 'The custom command conflicts with this chat’s model or effort. Remove model or effort flags in Settings → Agents → Command.' + ], [ 'the managed account env override', 'This Claude launch defines explicit Anthropic auth environment variables.', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 792276702381..855f0bcdabe4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -196,3 +196,25 @@ describe('a queued message whose start fails and whose child then exits', () => expect(publishedStartRows()).toEqual([EXIT_TEXT]) }) }) + +it.each(['customCommandInvalid', 'customCommandConflict'] as const)( + 'persists one actionable row for %s and keeps the same rejection on replay', + async (kind) => { + const queued = await sendQueued('hello') + const failure = agentSessionFailureFact(kind) + settleStart(failure) + await eventually(async () => + expect(await submission(queued)).toMatchObject({ + dispatchState: 'rejected', + rejection: failure + }) + ) + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + const rows = await startRows() + expect(rows).toHaveLength(1) + expect(rows[0]).toContain('Settings → Agents → Command') + expect(publishedStartRows()).toEqual(rows) + expect(await submission(queued)).toMatchObject({ rejection: failure, reason: rows[0] }) + } +) diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts new file mode 100644 index 000000000000..f6d413153eb9 --- /dev/null +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -0,0 +1,88 @@ +import { accessSync, chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveStructuredAgentCommand } from './structured-agent-command-resolution' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, statSync: vi.fn(actual.statSync), accessSync: vi.fn(actual.accessSync) } +}) + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-custom-command-')) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) +function program(name = 'wrapper') { + const file = join(root, name) + writeFileSync(file, '#!/bin/sh\nexit 0\n') + chmodSync(file, 0o755) + return file +} +function resolve(override: string, cwd = root) { + return resolveStructuredAgentCommand({ + agent: 'claude', + override, + cwd, + env: { HOME: root, PATH: root }, + platform: 'linux' + }) +} + +describe('custom program resolution on the execution host', () => { + it.each(['wrapper code', '~/wrapper code', './wrapper code'])( + 'resolves %s with host PATH/home/cwd', + (override) => { + const file = program() + expect(resolve(override)).toEqual({ command: file, prefixArgs: ['code'], cwd: root }) + } + ) + it('resolves a relative program from the workspace root', () => { + const file = program() + expect(resolve('./wrapper', root)).toEqual({ command: file, prefixArgs: [], cwd: root }) + }) + it('keeps quoted absolute paths and empty arguments', () => { + const file = program('wrapper with spaces') + expect(resolve(`"${file}" ""`)).toEqual({ command: file, prefixArgs: [''], cwd: root }) + }) + it.each(['missing-program', 'a_shell_alias', 'KEY=value wrapper', 'wrapper && echo done'])( + 'refuses %s without substituting a default', + (override) => { + expect(() => resolve(override)).toThrow( + expect.objectContaining({ reason: 'customCommandInvalid' }) + ) + } + ) + it('refuses a nonexecutable file', () => { + const file = program() + chmodSync(file, 0o644) + expect(() => resolve(file)).toThrow(expect.objectContaining({ reason: 'customCommandInvalid' })) + }) + it.each(['sh', 'env'])('refuses shell or environment replacement through %s', (name) => { + const file = program(name) + expect(() => resolve(`"${file}" -c 'echo x'`)).toThrow( + expect.objectContaining({ reason: 'customCommandInvalid' }) + ) + }) + it('retains an explicit Windows shim path for the shared spawn resolver', () => { + const stat = statSync(program()) + vi.mocked(statSync).mockReturnValue(stat) + vi.mocked(accessSync).mockImplementation(() => {}) + expect( + resolveStructuredAgentCommand({ + agent: 'codex', + override: '"C:\\Agent Tools\\codex.cmd" --profile work', + env: { USERPROFILE: 'C:\\Users\\Host' }, + platform: 'win32' + }) + ).toEqual({ + command: 'C:\\Agent Tools\\codex.cmd', + prefixArgs: ['--profile', 'work'] + }) + }) +}) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts new file mode 100644 index 000000000000..c55e0f5c1738 --- /dev/null +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -0,0 +1,81 @@ +import { accessSync, constants, statSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { + parseStructuredAgentCommandOverride, + validateStructuredAgentCommandArgs, + type StructuredAgentCommandInvocation +} from '../../shared/tui-agent-launch-command-override' +import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' + +export function resolveStructuredAgentCommand(input: { + agent: 'claude' | 'codex' + override: string | null | undefined + env: NodeJS.ProcessEnv + cwd?: string + platform?: NodeJS.Platform +}): StructuredAgentCommandInvocation | null { + const platform = input.platform ?? process.platform + try { + const invocation = parseStructuredAgentCommandOverride(input.override, platform) + if (!invocation) { + return null + } + const api = platform === 'win32' ? path.win32 : path.posix + const home = input.env.HOME ?? input.env.USERPROFILE ?? homedir() + let command = invocation.command + if (command.startsWith('~/') || (platform === 'win32' && command.startsWith('~\\'))) { + command = api.join(home, command.slice(2)) + } + command = + command.includes('/') || (platform === 'win32' && command.includes('\\')) + ? api.resolve(input.cwd ?? home, command) + : resolveCliCommand(command, { + platform, + pathEnv: Object.entries(input.env).find(([key]) => + platform === 'win32' ? key.toLowerCase() === 'path' : key === 'PATH' + )?.[1], + homePath: home + }) + if (!api.isAbsolute(command) || !statSync(command).isFile()) { + throw new Error('program not found') + } + if (platform === 'win32' && !/\.(exe|com|cmd|bat)$/i.test(command)) { + throw new Error('program not executable on Windows') + } + accessSync(command, platform === 'win32' ? constants.F_OK : constants.X_OK) + const shell = api + .basename(command) + .replace(/\.exe$/i, '') + .toLowerCase() + if (['sh', 'bash', 'zsh', 'fish', 'cmd', 'powershell', 'pwsh', 'env'].includes(shell)) { + throw new Error('custom command requires a shell') + } + const result = { + command, + prefixArgs: invocation.prefixArgs, + ...(input.cwd ? { cwd: input.cwd } : {}) + } + assertStructuredAgentCommandPreferences(input.agent, result) + return result + } catch (error) { + if (error instanceof AgentSessionPreSpawnError) { + throw error + } + throw new AgentSessionPreSpawnError(error, { reason: 'customCommandInvalid' }) + } +} + +export function assertStructuredAgentCommandPreferences( + agent: 'claude' | 'codex', + invocation: StructuredAgentCommandInvocation, + options?: Readonly> +): void { + const reason = validateStructuredAgentCommandArgs(agent, invocation.prefixArgs, options) + if (reason) { + throw new AgentSessionPreSpawnError('custom command conflicts with structured launch', { + reason + }) + } +} diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts index c578bdbbe68b..3a92bcbd169f 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 @@ -82,14 +82,14 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { }) it.each(['claude', 'codex'] as const)( - "refuses %s when this host overrides the agent's launch command", + "supports %s when this host overrides the agent's launch command", (agent) => { expect( support({ agent, getSettings: () => ({ ...HOST_SELECTED, agentCmdOverrides: { [agent]: 'wrapper' } }) }) - ).toEqual({ supported: false, reason: 'agent' }) + ).toEqual({ supported: true }) } ) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index a6323db075a1..9b96a1af4be0 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,7 +1,5 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { GlobalSettings } from '../../shared/global-settings-types' -import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' import { readClaudeManagedAccountGateSettings, structuredClaudeMatchesActiveManagedAccount, @@ -22,8 +20,7 @@ export function resolveStructuredAgentSessionCreateSupport(input: { agent: 'claude' | 'codex' location: AgentSessionExecutionLocation adapterSupportsCreate: boolean - getSettings: () => ClaudeManagedAccountGateSettings & - Partial> + getSettings: () => ClaudeManagedAccountGateSettings }): StructuredAgentSessionCreateSupport { if (!input.adapterSupportsCreate) { return { @@ -36,11 +33,6 @@ export function resolveStructuredAgentSessionCreateSupport(input: { : 'agent' } } - // This host's own launch command override names a process only a terminal runs, whichever - // client asked; a client routes on its own override for its own machine only. - if (hasExplicitTuiLaunchCommand(readSettingsOrNull(input.getSettings), input.agent)) { - return { supported: false, reason: 'agent' } - } // Claude only: Codex resolves its account on a different path, so its answer is untouched here. // `wsl` is the closest existing reason — the cause is a WSL-bound account rather than a WSL // workspace — and no client reads the field, so it stays as-is. @@ -54,11 +46,3 @@ export function resolveStructuredAgentSessionCreateSupport(input: { } return { supported: true } } - -function readSettingsOrNull(getSettings: () => T): T | null { - try { - return getSettings() - } catch { - return null - } -} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4333cc7e3a5e..8f9df3302d5f 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -150,6 +150,8 @@ 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, + resolveCommandOverride: (agent) => + this.requireStore().getSettings().agentCmdOverrides?.[agent], resolveLaunchEnvOverlay: () => resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeLaunchEnv: () => diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index c02075112d5d..7ff4b9b35dc9 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -87,12 +87,12 @@ describe('a structured default this dispatch cannot honour', () => { expect(decide({ params: { agent: 'codex', worktree: 'current' } }).mode).toBe('structured') }) - it('falls back rather than dropping a custom TUI launch the session cannot apply', () => { + it('keeps a custom Command on the structured worker route', () => { expect( decide({ settings: { ...STRUCTURED_DEFAULT, agentCmdOverrides: { claude: 'claude-wrapper' } } }) - ).toMatchObject({ mode: 'terminal', reason: 'tui_launch_command' }) + ).toMatchObject({ mode: 'structured', reason: 'user_default' }) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts index 5e84ea1fae7c..b5428d72d9bb 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -87,17 +87,18 @@ describe('worker-start mode receipt wording', () => { }) }) - it('names a custom TUI launch command as the downgrade', () => { + it('honors the structured preference with a custom Command', () => { expect( decideWorkerStartMode({ params: { agent: 'claude' }, settings: { ...STRUCTURED_PREFERENCE, agentCmdOverrides: { claude: 'claude-wrapper' } } }) ).toEqual({ - mode: 'terminal', + mode: 'structured', preferred: 'structured', - reason: 'tui_launch_command', - detail: downgradeSentence('this agent has a custom launch command that only a terminal runs') + reason: 'user_default', + detail: + 'Started a structured chat session worker, the default for new agent tabs in your settings.' }) }) diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index c7c79aebe614..6ac68611f73f 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -8,8 +8,14 @@ import { import { createCodexModelCatalogProbe } from '../codex/codex-model-catalog-probe' import { createClaudeModelCatalogProbe } from '../claude/claude-model-catalog-probe' import { workspaceMayOverrideDefaultModel } from '../native-chat/agent-model-catalog/agent-project-model-override' -import type { ClaudeStructuredLaunchResolverDeps } from '../claude/claude-structured-launch-resolution' -import type { CodexStructuredLaunchResolverDeps } from '../codex/codex-structured-launch-resolution' +import { + resolveClaudeStructuredInvocation, + type ClaudeStructuredLaunchResolverDeps +} from '../claude/claude-structured-launch-resolution' +import { + resolveCodexStructuredInvocation, + type CodexStructuredLaunchResolverDeps +} from '../codex/codex-structured-launch-resolution' import type { AgentSessionRecordStore } from './agent-session-record-store' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' @@ -48,11 +54,14 @@ export async function modelCatalogHostDeps(input: { StructuredAgentSessionRuntimeDeps, | 'stateDirectory' | 'resolveAgentAccountHome' + | 'resolveWorkspacePath' + | 'resolveCommandOverride' | 'resolveCodexCommand' | 'resolveClaudeCommand' | 'resolveClaudeLaunchEnv' | 'resolveClaudeAuthPolicy' > + readSessionCatalogAccess?: AgentModelCatalogServiceDeps['readSessionCatalogAccess'] envResolvers: { resolveCodexEnvironment: NonNullable resolveClaudeInheritedEnv: NonNullable< @@ -70,17 +79,48 @@ export async function modelCatalogHostDeps(input: { getRecord: (sessionId) => input.store.getRecord(sessionId) ?? undefined, resolveAccountHome: deps.resolveAgentAccountHome, workspaceMayOverrideDefaultModel, - probes: { - codex: createCodexModelCatalogProbe({ - resolveEnvironment: input.envResolvers.resolveCodexEnvironment, - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }), - claude: createClaudeModelCatalogProbe({ + resolveWorkspacePath: deps.resolveWorkspacePath, + readSessionCatalogAccess: input.readSessionCatalogAccess, + prepareProbe: async (agent, cwd, pinned) => { + if (agent === 'codex') { + const resolver = { + resolveEnvironment: input.envResolvers.resolveCodexEnvironment, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}), + resolveCommandOverride: () => deps.resolveCommandOverride?.('codex') + } + const resolved = await resolveCodexStructuredInvocation( + pinned + ? { + ...resolver, + resolveCommand: () => pinned.command, + resolveCommandOverride: () => undefined + } + : resolver, + cwd + ) + const invocation = pinned ? { ...resolved, ...pinned } : resolved + return { invocation, probe: createCodexModelCatalogProbe({ ...resolver, invocation, cwd }) } + } + const resolver = { resolveInheritedEnv: input.envResolvers.resolveClaudeInheritedEnv, resolveAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), - ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) - }) + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), + resolveCommandOverride: () => deps.resolveCommandOverride?.('claude') + } + const resolved = await resolveClaudeStructuredInvocation( + pinned + ? { + ...resolver, + resolveCommand: () => pinned.command, + resolveCommandOverride: () => undefined + } + : resolver, + undefined, + cwd + ) + const invocation = pinned ? { ...resolved, ...pinned } : resolved + return { invocation, probe: createClaudeModelCatalogProbe({ ...resolver, invocation, cwd }) } } }) return { modelCatalog } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5ce..fbd54acf09ad 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -96,6 +96,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string + resolveCommandOverride?: (agent: 'claude' | 'codex') => string | null | undefined /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] @@ -258,6 +259,7 @@ async function installOnJournal( store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: resolveCodexEnvironment, + resolveCommandOverride: () => deps.resolveCommandOverride?.('codex'), ...(deps.resolveCodexPermissionPolicy ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } : {}), @@ -282,6 +284,7 @@ async function installOnJournal( ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), resolveClaudeInheritedEnv, + resolveClaudeCommandOverride: () => deps.resolveCommandOverride?.('claude'), resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } @@ -322,7 +325,15 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), - ...(await modelCatalogHostDeps({ store, deps, envResolvers })) + ...(await modelCatalogHostDeps({ + store, + deps, + envResolvers, + readSessionCatalogAccess: (agent, sessionId) => + agent === 'claude' + ? claude.readCatalogAccess(sessionId) + : codex.readCatalogAccess(sessionId) + })) }) setStructuredAgentSessionHost(host) return { diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9024c65b503d..9b358ac7915f 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,6 +1,5 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' import { @@ -22,6 +21,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise resolveClaudeCommand?: () => string + resolveClaudeCommandOverride?: () => string | null | undefined resolveClaudeLaunchEnv?: () => Promise> | Record /** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */ resolveClaudeInheritedEnv?: () => Promise> @@ -80,7 +80,8 @@ export function createStructuredClaudeRuntimeAdapter( resolveLaunch: createClaudeStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, - resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, + ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), + resolveCommandOverride: deps.resolveClaudeCommandOverride, ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), ...(deps.resolveClaudeInheritedEnv ? { resolveInheritedEnv: deps.resolveClaudeInheritedEnv } diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx new file mode 100644 index 000000000000..b1eed8f1c490 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { + agentSessionRefusalFailure, + readAgentSessionErrorRefusal +} from '../../../../shared/agent-session-write-failure' +import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' + +afterEach(cleanup) + +it.each(['customCommandInvalid', 'customCommandConflict'] as const)( + 'shows actionable Command setting feedback for %s', + (reason) => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason } + }) + render( + {}} + /> + ) + expect(screen.getByText(/Settings → Agents → Command/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() + } +) + +it('keeps the generic start failure on a client that cannot recognize the host reason', () => { + const refusal = readAgentSessionErrorRefusal({ + response: { + error: { + data: { + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'commandReasonFromNewerHost' } + } + } + } + } + }) + if (!refusal) { + throw new Error('expected a parsed refusal') + } + const failure = agentSessionRefusalFailure(refusal) + expect(failure).toEqual({ kind: 'refused', code: 'agent_session_operation_invalid' }) + render( + {}} + /> + ) + expect(screen.getByText('Chat could not be started.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) + +it('keeps the generic start failure when the host failure kind is newer than the client', () => { + const fact = readAgentSessionFailureFact({ kind: 'commandKindFromNewerHost' }) + expect(fact).toBeUndefined() + render( {}} />) + expect(screen.getByText('Chat could not be started.')).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 34dab4ab94f6..e02ff233a0dc 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -58,6 +58,17 @@ const PIECES: Record translate('components.native-chat.failureWords.historyTooLarge', COPY.historyTooLarge), + customCommandInvalid: (values) => + translate( + 'components.native-chat.failureWords.customCommandInvalid', + COPY.customCommandInvalid, + values + ), + customCommandConflict: () => + translate( + 'components.native-chat.failureWords.customCommandConflict', + COPY.customCommandConflict + ), managedAccountEnvOverride: () => translate( 'components.native-chat.failureWords.managedAccountEnvOverride', diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index 4fa7e279878e..69696a0d5379 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -10,7 +10,6 @@ import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../../../shared/commit-message-host-key' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' -import { hasExplicitTuiLaunchCommand } from '../../../../shared/tui-agent-launch-command-override' import { getConnectionIdFromState } from '@/lib/connection-context' import { getLocalProjectExecutionRuntimeContext, @@ -100,13 +99,14 @@ function catalogModelsFromHostCatalog( /** Null when the host has no listing yet or predates the surface (`forbidden` * or `method_not_found`) — the caller then falls back to the CLI listing. */ async function readLocalHostCatalogModels( - agent: 'claude' | 'codex' + agent: 'claude' | 'codex', + context: RuntimeGitContext ): Promise { try { const result = await callStructuredAgentSession( { kind: 'local' }, 'agentSession.modelCatalog', - { agent } + { agent, ...(context.worktreeId ? { worktree: context.worktreeId } : {}) } ) if (result.origin === 'unknown' || result.models.length === 0) { return null @@ -127,13 +127,8 @@ export async function discoverNativeChatCatalogModels( const hostCatalogAgent = agent === 'claude' ? ('claude' as const) : agent === 'codex' ? ('codex' as const) : null // Only `local` proves a native pane: a paired runtime's key also covers its SSH/WSL worktrees. - // A custom launch command runs a binary the structured catalog never lists; the CLI listing honors it. - if ( - hostCatalogAgent && - hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY && - !hasExplicitTuiLaunchCommand(context.settings, hostCatalogAgent) - ) { - const fromHost = await readLocalHostCatalogModels(hostCatalogAgent) + if (hostCatalogAgent && hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY) { + const fromHost = await readLocalHostCatalogModels(hostCatalogAgent, context) if (fromHost) { return fromHost } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 15d890f319eb..c625a69830e0 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -49,6 +49,11 @@ describe('native chat session option enrichment', () => { const local = await discoverNativeChatCatalogModels('codex', context, 'local') expect(local?.map(({ id }) => id)).toEqual(['gpt-host']) + expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.modelCatalog', + { agent: 'codex', worktree: context.worktreeId } + ) expect(mocks.discoverRuntimeCommitMessageModels).not.toHaveBeenCalled() // A paired runtime's key also covers its SSH and WSL worktrees, which its @@ -59,7 +64,7 @@ describe('native chat session option enrichment', () => { expect(paired?.map(({ id }) => id)).toContain('gpt-cli') }) - it('lists through the CLI when this machine runs a custom launch command', async () => { + it('reads the host catalog with the host’s custom launch command', async () => { mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ success: true, catalogOrigin: 'probe', @@ -71,10 +76,11 @@ describe('native chat session option enrichment', () => { worktreePath: '/worktree' } - // The structured catalog lists the built-in binary, which this terminal does not run. + // The host catalog and the session use the same executable. const models = await discoverNativeChatCatalogModels('codex', context, 'local') - expect(mocks.callStructuredAgentSession).not.toHaveBeenCalled() - expect(models?.map(({ id }) => id)).toContain('gpt-custom') + expect(mocks.callStructuredAgentSession).toHaveBeenCalled() + expect(mocks.discoverRuntimeCommitMessageModels).not.toHaveBeenCalled() + expect(models?.map(({ id }) => id)).toContain('gpt-host') }) it('bounds settled host enrichment entries', async () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a623d23664c8..afcabffbec0f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17713,6 +17713,8 @@ "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", "historyTooLarge": "This conversation's history is too large to restore here.", + "customCommandInvalid": "{{agent}} couldn't start with your custom command. Use a program and its arguments in Settings → Agents → Command, or clear it.", + "customCommandConflict": "The custom command conflicts with this chat’s model or effort. Remove model or effort flags in Settings → Agents → Command.", "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 40f587ab41b4..4cfc198c7c3a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17491,6 +17491,8 @@ "signInThenRunCommand": "Inicie sesión y vuelva a ejecutar /{{command}}.", "signInThenSend": "Inicie sesión y vuelva a enviar su mensaje.", "historyTooLarge": "El historial de esta conversación es demasiado grande para restaurarlo aquí.", + "customCommandInvalid": "{{agent}} no pudo iniciarse con su comando personalizado. Use un programa y sus argumentos en Configuración → Agentes → Comando, o borre el comando.", + "customCommandConflict": "El comando personalizado entra en conflicto con el modelo o el esfuerzo de este chat. Elimina las opciones de modelo o esfuerzo en Configuración → Agentes → Comando.", "managedAccountEnvOverride": "Este inicio de Claude define sus propias variables de inicio de sesión de Anthropic. Quítelas para usar una cuenta de Claude administrada.", "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 606c04550625..7f6a8795fc7b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17590,6 +17590,8 @@ "signInThenRunCommand": "Connectez-vous, puis relancez /{{command}}.", "signInThenSend": "Connectez-vous, puis renvoyez votre message.", "historyTooLarge": "L'historique de cette conversation est trop volumineux pour être restauré ici.", + "customCommandInvalid": "{{agent}} n’a pas pu démarrer avec votre commande personnalisée. Indiquez un programme et ses arguments dans Paramètres → Agents → Commande, ou effacez la commande.", + "customCommandConflict": "La commande personnalisée entre en conflit avec le modèle ou l’effort de ce chat. Supprimez les options de modèle ou d’effort dans Paramètres → Agents → Commande.", "managedAccountEnvOverride": "Ce lancement de Claude définit ses propres variables de connexion Anthropic. Supprimez-les pour utiliser un compte Claude géré.", "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6d1b62a60fa0..109b57e0600b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17526,6 +17526,8 @@ "signInThenRunCommand": "サインインしてから、/{{command}} をもう一度実行してください。", "signInThenSend": "サインインしてから、メッセージをもう一度送信してください。", "historyTooLarge": "この会話の履歴は大きすぎるため、ここでは復元できません。", + "customCommandInvalid": "カスタムコマンドで {{agent}} を起動できませんでした。設定 → エージェント → コマンドでプログラムと引数を指定するか、コマンドを消去してください。", + "customCommandConflict": "カスタムコマンドがこのチャットのモデルまたは推論強度と競合しています。設定 → エージェント → コマンドからモデルまたは推論強度のフラグを削除してください。", "managedAccountEnvOverride": "この Claude の起動では独自の Anthropic サインイン変数が設定されています。管理対象の Claude アカウントを使用するには、それらを削除してください。", "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d9827f023364..4645b108997c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17526,6 +17526,8 @@ "signInThenRunCommand": "로그인한 다음 /{{command}}을(를) 다시 실행하세요.", "signInThenSend": "로그인한 다음 메시지를 다시 보내세요.", "historyTooLarge": "이 대화의 기록이 너무 커서 여기에서 복원할 수 없습니다.", + "customCommandInvalid": "사용자 지정 명령으로 {{agent}}을(를) 시작하지 못했습니다. 설정 → 에이전트 → 명령에 프로그램과 인수를 입력하거나 명령을 지우세요.", + "customCommandConflict": "사용자 지정 명령이 이 채팅의 모델 또는 추론 강도와 충돌합니다. 설정 → 에이전트 → 명령에서 모델 또는 추론 강도 옵션을 제거하세요.", "managedAccountEnvOverride": "이 Claude 실행은 자체 Anthropic 로그인 변수를 설정합니다. 관리되는 Claude 계정을 사용하려면 해당 변수를 제거하세요.", "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.", "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 795419d9a842..89c1da539f6d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17491,6 +17491,8 @@ "signInThenRunCommand": "请登录,然后重新运行 /{{command}}。", "signInThenSend": "请登录,然后重新发送您的消息。", "historyTooLarge": "此对话的历史记录过大,无法在此恢复。", + "customCommandInvalid": "无法使用自定义命令启动 {{agent}}。请在设置 → 智能体 → 命令中填写程序及其参数,或清空命令。", + "customCommandConflict": "自定义命令与此聊天的模型或推理强度冲突。请在设置 → 智能体 → 命令中移除模型或推理强度选项。", "managedAccountEnvOverride": "此次 Claude 启动设置了自己的 Anthropic 登录变量。请移除这些变量以使用受管理的 Claude 账户。", "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。", "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index b78a9c127408..744298bf7f20 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -248,19 +248,29 @@ describe('buildAgentLaunchRouteInput', () => { expect(structuredFeasibleFor(store(), args)).toBe(true) }) - it.each([ - ['a cwd', { cwd: '/repo/sub' }, {}], - ['a settings command override', {}, { agentCmdOverrides: { codex: 'codex-nightly' } }] - ] as const)('requires a terminal for %s', (_name, tuiCustomization, settingsOverride) => { - const input = buildAgentLaunchRouteInput( - store({ ...STRUCTURED_SETTINGS, ...settingsOverride }), - { - agent: 'codex', - workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, - tuiCustomization - } - ) - expect(input.requiresTuiLaunchCommand).toBe(true) + it.each([['a cwd', { cwd: '/repo/sub' }, {}]] as const)( + 'requires a terminal for %s', + (_name, tuiCustomization, settingsOverride) => { + const input = buildAgentLaunchRouteInput( + store({ ...STRUCTURED_SETTINGS, ...settingsOverride }), + { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + tuiCustomization + } + ) + expect(input.requiresTuiLaunchCommand).toBe(true) + } + ) + + it.each(['claude', 'codex'] as const)('keeps %s structured with a custom Command', (agent) => { + const appStore = store({ + ...STRUCTURED_SETTINGS, + agentCmdOverrides: { [agent]: 'wrapper subcommand' } + }) + const args = { agent, workspace: { kind: 'git-worktree' as const, worktreeId: 'wt-1' } } + expect(routeFor(appStore, args)).toBe('structured-native-chat') + expect(buildAgentLaunchRouteInput(appStore, args).requiresTuiLaunchCommand).toBe(false) }) // The reported P0: `--dangerously-skip-permissions --model Opus` matched no blessed string, so @@ -399,7 +409,7 @@ describe('buildAgentLaunchRouteInput', () => { } }) - // The override is this machine's; the server's createSupport applies its own. + // The execution host resolves its own Command setting. it("does not apply this machine's launch command override to the server", () => { const settings = { ...STRUCTURED_SETTINGS, agentCmdOverrides: { claude: 'claude-wrapper' } } expect( diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index 1d23ef6c2130..4ae3dfe61b90 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -8,10 +8,7 @@ import { findWorktreeById } from '@/store/slices/worktree-helpers' import { requestsCwdOutsideWorkspaceRootForWorkspace } from '../../../shared/terminal-startup-cwd' import type { TuiAgent } from '../../../shared/tui-agent' import { workspaceKindForWorktreeId } from '../../../shared/workspace-launch-kind' -import { - hasExplicitTuiLaunchCommand, - type AgentLaunchRoutingInput -} from '@/lib/agent-launch-routing' +import type { AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' // Why: the `connection-context` facade imports the store root; the resolver's own module keeps // this input builder importable from anywhere in the launch graph without a cycle. import { @@ -157,19 +154,15 @@ export function buildAgentLaunchRouteInput( ), // A cwd decides the route only when it names somewhere other than the workspace root; the // host applies the same rule (`agent-launch-mode.ts`), so the two never disagree on it. - requiresTuiLaunchCommand: - requestsCwdOutsideWorkspaceRootForWorkspace({ - workspaceId: workspace.worktreeId, - requestedCwd: tuiCustomization?.cwd, - workspacePath: workspace.worktreeId - ? findWorktreeById(store.worktreesByRepo ?? {}, workspace.worktreeId)?.path - : undefined, - resolveFolderWorkspacePath: (folderWorkspaceId) => - store.folderWorkspaces?.find((entry) => entry.id === folderWorkspaceId)?.folderPath - }) || - // A launch command override is this machine's; a paired host's createSupport reads its own. - (executionHostId === LOCAL_EXECUTION_HOST_ID && - hasExplicitTuiLaunchCommand(store.settings, agent)), + requiresTuiLaunchCommand: requestsCwdOutsideWorkspaceRootForWorkspace({ + workspaceId: workspace.worktreeId, + requestedCwd: tuiCustomization?.cwd, + workspacePath: workspace.worktreeId + ? findWorktreeById(store.worktreesByRepo ?? {}, workspace.worktreeId)?.path + : undefined, + resolveFolderWorkspacePath: (folderWorkspaceId) => + store.folderWorkspaces?.find((entry) => entry.id === folderWorkspaceId)?.folderPath + }), initialSessionOptions: args.initialSessionOptions } } diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index 72ee6ffbf8da..6bfc082ffb21 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -5,11 +5,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' -import { - hasExplicitTuiLaunchCommand, - resolveAgentLaunchRoute, - structuredAgentLaunchSupported -} from './agent-launch-routing' +import { resolveAgentLaunchRoute, structuredAgentLaunchSupported } from './agent-launch-routing' const settings = { experimentalNativeChat: true, @@ -179,15 +175,6 @@ describe('resolveAgentLaunchRoute', () => { }) ).toBe('legacy-native-chat') }) - - it('treats a whitespace-only command override as no override', () => { - expect(hasExplicitTuiLaunchCommand({ agentCmdOverrides: { codex: ' ' } }, 'codex')).toBe( - false - ) - expect( - hasExplicitTuiLaunchCommand({ agentCmdOverrides: { codex: 'codex-nightly' } }, 'codex') - ).toBe(true) - }) }) describe('explicit structured chat requests', () => { diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 04850cdf91db..7938543230de 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -11,8 +11,6 @@ import { type NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' -export { hasExplicitTuiLaunchCommand } from '../../../shared/tui-agent-launch-command-override' - export type AgentLaunchRoute = 'structured-native-chat' | 'legacy-native-chat' | 'terminal-tui' export type AgentLaunchRoutingInput = { diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index bffb93c1b9a2..db3d5fe48898 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -68,8 +68,8 @@ export type AgentLaunchIntent = { * caller explicitly wants none, and collapsing the two would make a recipe that clears its args * silently inherit whatever the settings happen to hold. * - * Deliberately NOT a route input. `hasExplicitTuiLaunchCommand` reads the launch *command* and - * pointedly not the arguments, because structured chat drives Claude through the Agent SDK and + * Deliberately not a route input: structured chat honors Command, while Arguments remain a + * terminal concern because structured chat drives Claude through the Agent SDK and * Codex through app-server, whose option sets are versioned independently of the interactive * CLI's. So args reaching a structured launch are ignored rather than forcing a terminal — the * host says so in `warning` instead of quietly honouring neither the args nor the preference. diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 1f3a58f3badd..807bc073531c 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -24,6 +24,10 @@ export const AGENT_SESSION_FAILURE_COPY = { signInThenRunCommand: 'Sign in, then run /{{command}} again.', signInThenSend: 'Sign in, then send your message again.', historyTooLarge: "This conversation's history is too large to restore here.", + customCommandInvalid: + "{{agent}} couldn't start with your custom command. Use a program and its arguments in Settings → Agents → Command, or clear it.", + customCommandConflict: + 'The custom command conflicts with this chat’s model or effort. Remove model or effort flags in Settings → Agents → Command.', managedAccountEnvOverride: 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', accountSwitchInProgress: 'A Claude account switch is in progress. Try again after it finishes.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index f64ba5cb3352..8148c3e8b9ce 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -199,6 +199,9 @@ const FAILURE_SENTENCES = { ]), historyTooLarge: (_context, _fact, _surface, say) => joinSentences([say('historyTooLarge'), say('startNewChat')]), + customCommandInvalid: (context, _fact, _surface, say) => + say('customCommandInvalid', agent(say, context)), + customCommandConflict: (_context, _fact, _surface, say) => say('customCommandConflict'), managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'), accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'), managedAccountUnsupported: (context, _fact, _surface, say) => diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b08c5250a150..bd8c55ac359e 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -20,6 +20,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'startFailed', 'notSignedIn', 'historyTooLarge', + 'customCommandInvalid', + 'customCommandConflict', 'managedAccountEnvOverride', 'accountSwitchInProgress', 'managedAccountUnsupported', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 70c6121d03f5..40fc8cc033ad 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -47,6 +47,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'notSignedIn', 'historyTooLarge', /** The launch's own Anthropic sign-in variables would override the managed Claude account. */ + 'customCommandInvalid', + 'customCommandConflict', 'managedAccountEnvOverride', 'accountSwitchInProgress', /** A Claude account is added in WSL and no Windows one is selected, which a chat can't run under. */ diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 2aaaac158ff2..eb6598cf9bba 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -93,6 +93,8 @@ const REASON_WORDS = { providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + customCommandInvalid: { fact: 'customCommandInvalid', action: 'actFirst' }, + customCommandConflict: { fact: 'customCommandConflict', action: 'actFirst' }, managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index c7407ac5eb13..9d1aa433a79a 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -11,7 +11,9 @@ type ResolveCommandOptions = { function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { if (platform === 'win32') { - return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] + return /\.(cmd|exe|bat|com)$/i.test(commandName) + ? [commandName] + : [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] } return [commandName] diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 57b8e745ca52..fce697de9c10 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -87,6 +87,8 @@ const KIND_CATEGORY = { startFailed: 'startFailed', notSignedIn: 'startFailed', historyTooLarge: 'startFailed', + customCommandInvalid: 'startFailed', + customCommandConflict: 'startFailed', managedAccountEnvOverride: 'startFailed', accountSwitchInProgress: 'startFailed', managedAccountUnsupported: 'startFailed', @@ -112,6 +114,8 @@ const KIND_VERDICT = { startFailed: 'failure', notSignedIn: 'failure', historyTooLarge: 'failure', + customCommandInvalid: 'failure', + customCommandConflict: 'failure', managedAccountEnvOverride: 'failure', accountSwitchInProgress: 'failure', managedAccountUnsupported: 'failure', diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 8f5e0f40c2e9..70e21618d496 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -61,7 +61,11 @@ describe('per-launch structured feasibility', () => { ['grok', { agent: 'grok' }, 'agent-without-structured-session'], ['openclaude', { agent: 'openclaude' }, 'agent-without-structured-session'], ['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'], - ['a custom TUI launch command', { requiresTuiLaunchCommand: true }, 'tui-launch-command'], + [ + 'a working directory outside the workspace root', + { requiresTuiLaunchCommand: true }, + 'tui-launch-command' + ], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'], ['an unanswered host', { hostCapabilities: null }, 'runtime-capability-unknown'] diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index eac3a87268e3..6044ae1f0476 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -29,9 +29,8 @@ export type StructuredNativeChatBlocker = | 'reused-terminal' | 'agent-without-structured-session' | 'floating-workspace' - /** The agent's launch command is overridden, or the launch names its own working directory: - * a process shape only a PTY can produce. The configured *arguments* are not read here — - * they are a terminal concern the structured transports do not share a vocabulary with. */ + /** The launch starts outside the workspace root. Command overrides stay on the structured + * route; the separate Arguments field remains a terminal concern. */ | 'tui-launch-command' | 'remote-execution-host' | 'project-runtime' diff --git a/src/shared/tui-agent-launch-command-override.test.ts b/src/shared/tui-agent-launch-command-override.test.ts new file mode 100644 index 000000000000..f80578b3cf18 --- /dev/null +++ b/src/shared/tui-agent-launch-command-override.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + parseStructuredAgentCommandOverride, + validateStructuredAgentCommandArgs +} from './tui-agent-launch-command-override' + +describe('structured agent Command parsing', () => { + it.each([undefined, '', ' '])('uses the default for %s', (value) => { + expect(parseStructuredAgentCommandOverride(value, 'linux')).toBeNull() + }) + it.each([ + ['ccr code', 'ccr', ['code']], + ['npx @anthropic-ai/claude-code', 'npx', ['@anthropic-ai/claude-code']], + ['"/tools with spaces/claude" --profile ""', '/tools with spaces/claude', ['--profile', '']], + ["claude '--label=$HOME & *'", 'claude', ['--label=$HOME & *']], + ['~/bin/my-claude', '~/bin/my-claude', []], + ['claude --label escaped\\&literal', 'claude', ['--label', 'escaped&literal']] + ] as const)('preserves literal argv in %s', (value, command, prefixArgs) => { + expect(parseStructuredAgentCommandOverride(value, 'darwin')).toEqual({ command, prefixArgs }) + }) + it('preserves a quoted Windows executable and backslashes', () => { + expect( + parseStructuredAgentCommandOverride('"C:\\Agent Tools\\wrapper.cmd" code ""', 'win32') + ).toEqual({ + command: 'C:\\Agent Tools\\wrapper.cmd', + prefixArgs: ['code', ''] + }) + }) + it.each([ + 'claude | cat', + 'claude && echo done', + 'claude > out', + 'claude; echo done', + 'KEY=value claude', + 'claude $FLAGS', + 'claude "${FLAGS}"', + 'claude $(echo x)', + 'claude `echo x`', + 'claude *.txt', + 'claude "unclosed', + 'claude\0', + 'claude\necho x' + ])('rejects shell-only command %s', (command) => { + expect(() => parseStructuredAgentCommandOverride(command, 'linux')).toThrow() + }) + it.each(['claude %USERPROFILE%', 'claude $env:FLAGS', 'claude & other'])( + 'rejects Windows expansion %s', + (command) => { + expect(() => parseStructuredAgentCommandOverride(command, 'win32')).toThrow() + } + ) +}) + +describe('structured command preference and transport ownership', () => { + it.each([ + ['claude', ['--model=opus'], { model: 'opus' }], + ['claude', ['--effort', 'high'], { effort: 'high' }], + ['codex', ['-m', 'custom-model'], { model: 'custom-model' }], + ['codex', ['-cmodel=custom-model'], { model: 'custom-model' }], + ['codex', ['--config', 'model_reasoning_effort=high'], { effort: 'high' }], + ['codex', ['--reasoning-effort=high'], { model: 'unknown-model', effort: 'high' }] + ] as const)('rejects %s flags competing with a requested pick', (agent, args, options) => { + expect(validateStructuredAgentCommandArgs(agent, args, options)).toBe('customCommandConflict') + }) + it.each(['claude', 'codex'] as const)( + 'keeps %s model defaults without a competing pick', + (agent) => { + expect(validateStructuredAgentCommandArgs(agent, ['--model', 'custom'])).toBeUndefined() + } + ) + it.each([ + ['claude', ['--session-id=x']], + ['claude', ['--input-format=text']], + ['claude', ['--permission-mode=bypassPermissions']], + ['codex', ['--listen=tcp://host']], + ['codex', ['-c', 'sandbox_mode=disabled']], + ['codex', ['--config=approval_policy=never']], + ['codex', ['--cd', '/other']], + ['claude', ['--settings', 'account.json']], + ['codex', ['--']], + ['claude', ['CLAUDE_CONFIG_DIR=/other']] + ] as const)('rejects %s transport/account flags', (agent, args) => { + expect(validateStructuredAgentCommandArgs(agent, args)).toBe('customCommandInvalid') + }) + it('does not mistake a required option value for a model flag', () => { + expect( + validateStructuredAgentCommandArgs('claude', ['--append-system-prompt', '--model'], { + model: 'opus' + }) + ).toBeUndefined() + }) +}) + +it.each(['claude', 'codex'] as const)( + 'treats cleared %s picks as no requested preference', + (agent) => { + expect( + validateStructuredAgentCommandArgs(agent, ['--model', 'custom'], { model: '', effort: '' }) + ).toBeUndefined() + } +) +it('allows a required Claude option whose literal value is the terminator', () => { + expect( + validateStructuredAgentCommandArgs('claude', ['--append-system-prompt', '--']) + ).toBeUndefined() +}) diff --git a/src/shared/tui-agent-launch-command-override.ts b/src/shared/tui-agent-launch-command-override.ts index 0dea2c02754e..d22a3e4b4a3f 100644 --- a/src/shared/tui-agent-launch-command-override.ts +++ b/src/shared/tui-agent-launch-command-override.ts @@ -1,21 +1,151 @@ -import type { GlobalSettings } from './global-settings-types' -import type { TuiAgent } from './tui-agent' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { agentArgTerminatorIndex, removeAgentArgOption } from './agent-session-option-agent-args' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' -/** - * Whether the user replaced this agent's launch command with one only a terminal can run. - * - * Shared rather than renderer-local because both launch surfaces have to answer it: the renderer - * routes such a launch back to the TUI, and orchestration falls a worker back to a PTY so the - * custom command still applies. - * - * Arguments and environment are deliberately not read here. Structured native chat applies the - * configured environment itself, and the Arguments field is a terminal/TUI concern: structured - * chat drives Claude through the Agent SDK and Codex through app-server, whose option sets are - * independently versioned and need not match the interactive CLI's. - */ -export function hasExplicitTuiLaunchCommand( - settings: Partial> | null | undefined, - agent: TuiAgent -): boolean { - return Boolean(settings?.agentCmdOverrides?.[agent]?.trim()) +export type StructuredAgentCommandInvocation = { + command: string + prefixArgs: readonly string[] + /** Custom wrappers can resolve scripts and configuration relative to this directory. */ + cwd?: string +} + +/** Command applies to structured chat too; the separate Arguments field remains terminal-only. */ +export function parseStructuredAgentCommandOverride( + command: string | null | undefined, + platform: NodeJS.Platform +): StructuredAgentCommandInvocation | null { + const value = command?.trim() + if (!value) { + return null + } + const parsed = tokenizeStartupCommand(value, platform === 'win32' ? 'powershell' : 'posix') + if ( + /[\0\r\n]/.test(value) || + !parsed.ok || + parsed.spans.some((span) => span.divergesFromShell) || + hasShellExpansion(value, platform) + ) { + throw new Error('custom command requires shell evaluation') + } + const [binary, ...prefixArgs] = parsed.tokens + if (!binary || /^[A-Za-z_][A-Za-z0-9_]*=/.test(binary)) { + throw new Error('custom command must name a program') + } + return { command: binary, prefixArgs } +} + +function hasShellExpansion(value: string, platform: NodeJS.Platform): boolean { + let quote: string | null = null + for (let index = 0; index < value.length; index += 1) { + const char = value[index] + if (char === (platform === 'win32' ? '`' : '\\') && quote !== "'") { + index += 1 + continue + } + if (quote === "'" && char !== "'") { + continue + } + if (char === quote) { + quote = null + continue + } + if (!quote && (char === "'" || char === '"')) { + quote = char + continue + } + if (char === '$' || char === '`' || (!quote && '*?[]{}()'.includes(char))) { + return true + } + if (platform === 'win32' && char === '%' && /%[^%]+%/.test(value.slice(index))) { + return true + } + } + return false +} + +/** Reject flags that would take ownership of the structured transport or a requested pick. */ +export function validateStructuredAgentCommandArgs( + agent: 'claude' | 'codex', + args: readonly string[], + options: Readonly> = {} +): 'customCommandInvalid' | 'customCommandConflict' | undefined { + const owned = + agent === 'claude' + ? [ + '-p', + '--print', + '--input-format', + '--output-format', + '--verbose', + '--session-id', + '--resume', + '-r', + '--continue', + '-c', + '--fork-session', + '--permission-prompt-tool', + '--permission-mode', + '--dangerously-skip-permissions', + '--allow-dangerously-skip-permissions', + '--settings', + '--setting-sources', + '--resume-session-at', + '--permission-prompts', + '--remote', + '--teleport', + '--environment', + '--worktree', + '-w', + '--replay-user-messages' + ] + : [ + '--listen', + '--cwd', + '--cd', + '-C', + '--session', + '--resume', + '--sandbox', + '--ask-for-approval', + '--dangerously-bypass-approvals-and-sandbox' + ] + if ( + agentArgTerminatorIndex(agent, args) < args.length || + args.some((arg) => /^(CODEX_HOME|CLAUDE_CONFIG_DIR)=/.test(arg)) || + removeAgentArgOption(agent, args, owned).length !== args.length || + (agent === 'codex' && + removeAgentArgOption(agent, args, ['-c', '--config'], (value) => + /^(sandbox_mode|approval_policy|forced_login_method|forced_chatgpt_workspace_id|cli_auth_credentials_store)\s*=/.test( + value ?? '' + ) + ).length !== args.length) + ) { + return 'customCommandInvalid' + } + const catalog = getAgentSessionOptionCatalog(agent) + if (!catalog) { + return undefined + } + const removesModel = catalog.modelApply.removeAgentArgs + if ( + typeof options.model === 'string' && + options.model.trim() !== '' && + removesModel && + removesModel(args).length !== args.length + ) { + return 'customCommandConflict' + } + const effort = + catalog.unknownModelOptions?.find((option) => option.id === 'effort') ?? + catalog.models.flatMap((model) => model.options ?? []).find((option) => option.id === 'effort') + const removesEffort = effort?.apply.removeAgentArgs + if ( + typeof options.effort === 'string' && + options.effort.trim() !== '' && + removesEffort && + removesEffort(args).length !== args.length + ) { + return 'customCommandConflict' + } + return undefined } diff --git a/tests/e2e/completed-worker-retirement-resume.spec.ts b/tests/e2e/completed-worker-retirement-resume.spec.ts index f626b978118e..534548116de2 100644 --- a/tests/e2e/completed-worker-retirement-resume.spec.ts +++ b/tests/e2e/completed-worker-retirement-resume.spec.ts @@ -50,6 +50,7 @@ for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) { await orcaPage.evaluate( async ({ agentCommand, terminalWindowsShell }) => { await window.__store?.getState().updateSettings({ + experimentalStructuredNativeChat: false, agentCmdOverrides: { codex: agentCommand }, terminalWindowsShell, disabledTuiAgents: [], diff --git a/tests/e2e/helpers/golden-stub-agent.ts b/tests/e2e/helpers/golden-stub-agent.ts index 393b244cf8fb..72757ee6ae01 100644 --- a/tests/e2e/helpers/golden-stub-agent.ts +++ b/tests/e2e/helpers/golden-stub-agent.ts @@ -40,6 +40,7 @@ export async function configureGoldenStubAgent( } await store.getState().updateSettings({ defaultTuiAgent: agent, + experimentalStructuredNativeChat: false, agentCmdOverrides: { [agent]: 'golden-stub-agent' }, agentDefaultArgs: { [agent]: agentArgs }, ...(windowsShell ? { terminalWindowsShell: windowsShell } : {}) diff --git a/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts index 9b5db8f59ec9..99a56bf7a441 100644 --- a/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts @@ -217,6 +217,7 @@ test('a missing legacy worker cannot spawn a replacement during restart recovery await first.page.evaluate( async ({ agentCommand, terminalWindowsShell }) => { await window.__store?.getState().updateSettings({ + experimentalStructuredNativeChat: false, agentCmdOverrides: { codex: agentCommand }, terminalWindowsShell }) diff --git a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts index 76a490bd1a39..510723f0611b 100644 --- a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts @@ -441,6 +441,7 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION await first.page.evaluate( async ({ agentCommand, terminalWindowsShell }) => { await window.__store?.getState().updateSettings({ + experimentalStructuredNativeChat: false, agentCmdOverrides: { codex: agentCommand }, terminalWindowsShell }) diff --git a/tests/e2e/orchestration-worker-settlement-release-cli.spec.ts b/tests/e2e/orchestration-worker-settlement-release-cli.spec.ts index 7e978049459e..773d81b13932 100644 --- a/tests/e2e/orchestration-worker-settlement-release-cli.spec.ts +++ b/tests/e2e/orchestration-worker-settlement-release-cli.spec.ts @@ -142,6 +142,7 @@ test('compiled CLI rejects false completion then reconciles the dead retained wo await orcaPage.evaluate( async ({ agentCommand, terminalWindowsShell }) => { await window.__store?.getState().updateSettings({ + experimentalStructuredNativeChat: false, agentCmdOverrides: { codex: agentCommand }, terminalWindowsShell }) diff --git a/tests/e2e/orchestration-worker-terminal-visibility.spec.ts b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts index 9b3aa2bc2aae..e9967b49c7a3 100644 --- a/tests/e2e/orchestration-worker-terminal-visibility.spec.ts +++ b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts @@ -118,6 +118,7 @@ test('worker-start preserves one live inactive worker across workspace re-entry' await orcaPage.evaluate( async ({ agentCommand, terminalWindowsShell }) => { await window.__store?.getState().updateSettings({ + experimentalStructuredNativeChat: false, agentCmdOverrides: { codex: agentCommand }, terminalWindowsShell }) diff --git a/tests/e2e/orchestration-worker-transcript-providers.spec.ts b/tests/e2e/orchestration-worker-transcript-providers.spec.ts index 903c55ca24a8..7f6c9715465b 100644 --- a/tests/e2e/orchestration-worker-transcript-providers.spec.ts +++ b/tests/e2e/orchestration-worker-transcript-providers.spec.ts @@ -215,6 +215,7 @@ test('worker-read uses provider transcripts across supported orchestration agent await orcaPage.evaluate( async ({ commands, terminalWindowsShell }) => { await window.__store?.getState().updateSettings({ + experimentalStructuredNativeChat: false, agentCmdOverrides: commands, terminalWindowsShell, disabledTuiAgents: [], diff --git a/tests/e2e/settled-worker-tab-survives-restart.spec.ts b/tests/e2e/settled-worker-tab-survives-restart.spec.ts index 10de089ec5ea..944341da37d5 100644 --- a/tests/e2e/settled-worker-tab-survives-restart.spec.ts +++ b/tests/e2e/settled-worker-tab-survives-restart.spec.ts @@ -133,6 +133,7 @@ for (const daemonSessionGone of [false, true]) { await first.page.evaluate( async ({ agentCommand, terminalWindowsShell }) => { await window.__store?.getState().updateSettings({ + experimentalStructuredNativeChat: false, agentCmdOverrides: { codex: agentCommand }, terminalWindowsShell, disabledTuiAgents: [], diff --git a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts index 906ab949a014..395710fe5ae0 100644 --- a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts +++ b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts @@ -58,7 +58,7 @@ describe('shared feasibility owns every caller decision', () => { for (const agent of ['claude', 'codex', 'grok', 'openclaude'] as const) { for (const customized of [false, true]) { // Arguments and environment are customized on BOTH passes, so the flag below tracks the - // launch command alone. A caller that resumed reading either one fails here. + // launch command alone. None of these settings changes structured feasibility. const launchSettings: Partial & typeof settings = { ...settings, agentDefaultArgs: { [agent]: '--custom' }, @@ -73,7 +73,7 @@ describe('shared feasibility owns every caller decision', () => { agent, executionHostId: 'local', reusesTerminal: Boolean(placement.terminal), - requiresTuiLaunchCommand: customized + requiresTuiLaunchCommand: false }) ) for (const blocker of blockers) { From 566e4790266864fcc5865b45ebc0addc7ac40f8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:21:20 -0700 Subject: [PATCH 2/7] fix(native-chat): sync launch failure localization catalogs --- src/renderer/src/i18n/en-runtime-required.json | 2 ++ src/renderer/src/i18n/locales/ja.json | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d72e86a111d1..08eba0c911fc 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2781,6 +2781,8 @@ "compactionUnconfirmed": "Compaction completion is unconfirmed.", "couldNotRestart": "{{agent}} couldn't restart.", "couldNotStart": "{{agent}} couldn't start.", + "customCommandConflict": "The custom command conflicts with this chat’s model or effort. Remove model or effort flags in Settings → Agents → Command.", + "customCommandInvalid": "{{agent}} couldn't start with your custom command. Use a program and its arguments in Settings → Agents → Command, or clear it.", "emptyMessage": "This message is empty, so it was not sent.", "historyTooLarge": "This conversation's history is too large to restore here.", "hostFault": "Orca ran into a problem, so this didn't go through.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3aab5d1b5954..0e189ec0b9f8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17562,8 +17562,8 @@ "signInThenRunCommand": "サインインしてから、/{{command}} をもう一度実行してください。", "signInThenSend": "サインインしてから、メッセージをもう一度送信してください。", "historyTooLarge": "この会話の履歴は大きすぎるため、ここでは復元できません。", - "customCommandInvalid": "カスタムコマンドで {{agent}} を起動できませんでした。設定 → エージェント → コマンドでプログラムと引数を指定するか、コマンドを消去してください。", - "customCommandConflict": "カスタムコマンドがこのチャットのモデルまたは推論強度と競合しています。設定 → エージェント → コマンドからモデルまたは推論強度のフラグを削除してください。", + "customCommandInvalid": "カスタムコマンドで {{agent}} を起動できませんでした。設定 → Agent → コマンドでプログラムと引数を指定するか、コマンドを消去してください。", + "customCommandConflict": "カスタムコマンドがこのチャットのモデルまたは推論強度と競合しています。設定 → Agent → コマンドからモデルまたは推論強度のフラグを削除してください。", "managedAccountEnvOverride": "この Claude の起動では独自の Anthropic サインイン変数が設定されています。管理対象の Claude アカウントを使用するには、それらを削除してください。", "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", From cf924e89de653629343148ecc5066d6cbbc38106 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:57:43 -0700 Subject: [PATCH 3/7] =?UTF-8?q?feat(native-chat):=20run=20Settings=20?= =?UTF-8?q?=E2=86=92=20Agents=20=E2=86=92=20Command=20as=20the=20native=20?= =?UTF-8?q?chat=20program?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native chat ignored the Command setting, so a user who pointed it at a custom Claude or Codex build still got the stock CLI. The host now reads the Command per acquisition and per model-catalog probe, resolves it on the execution host as a program (an absolute path, ~ path, or a name on PATH), and spawns it with the normal structured arguments. A set Command that is not a runnable program refuses the start with a new agentCommandNotRunnable failure that names the setting; it never falls back to the stock CLI. --- .../agent-hooks/local-agent-cli-presence.ts | 11 +- .../agent-launch-executor.test.ts | 2 +- .../agent-launch/agent-launch-executor.ts | 2 +- .../claude/claude-model-catalog-probe.test.ts | 14 +++ .../codex/codex-model-catalog-probe.test.ts | 14 +++ .../structured-agent-session-adapter.ts | 1 + ...uctured-agent-session-failure-text.test.ts | 4 + .../structured-agent-session-failure-text.ts | 3 +- ...ent-session-pre-spawn-first-answer.test.ts | 6 + .../structured-agent-program.test.ts | 53 ++++++++ .../native-chat/structured-agent-program.ts | 29 +++++ ...tured-agent-session-create-support.test.ts | 2 +- .../runtime/orca-runtime-get-worktree-ps.ts | 13 ++ ...me-structured-agent-program-wiring.test.ts | 88 +++++++++++++ .../orchestration-worker-start-mode.test.ts | 2 +- ...structured-agent-program-threading.test.ts | 115 +++++++++++++++++ .../structured-agent-session-runtime.ts | 2 + ...veChatLaunchRetry.command-failure.test.tsx | 28 +++++ .../agent-session-failure-words-text.ts | 6 + .../native-chat-session-option-discovery.ts | 3 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/agent-launch-route-input.test.ts | 4 +- src/shared/agent-launch-intent.ts | 10 +- src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + src/shared/configured-cli-program.test.ts | 117 ++++++++++++++++++ src/shared/configured-cli-program.ts | 64 ++++++++++ src/shared/node-cli-command-resolution.ts | 31 +++-- ...ctured-agent-session-dispatch-rejection.ts | 2 + .../structured-native-chat-launch-route.ts | 2 +- .../tui-agent-launch-command-override.ts | 8 +- 40 files changed, 614 insertions(+), 37 deletions(-) create mode 100644 src/main/native-chat/structured-agent-program.test.ts create mode 100644 src/main/native-chat/structured-agent-program.ts create mode 100644 src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts create mode 100644 src/main/runtime/structured-agent-program-threading.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx create mode 100644 src/shared/configured-cli-program.test.ts create mode 100644 src/shared/configured-cli-program.ts diff --git a/src/main/agent-hooks/local-agent-cli-presence.ts b/src/main/agent-hooks/local-agent-cli-presence.ts index c688508a5121..b37862fdd1b6 100644 --- a/src/main/agent-hooks/local-agent-cli-presence.ts +++ b/src/main/agent-hooks/local-agent-cli-presence.ts @@ -3,6 +3,7 @@ import { access, stat } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import type { AgentHookTarget } from '../../shared/agent-hook-types' +import { expandHomePathToken } from '../../shared/configured-cli-program' import { extractExecutableToken, hasPathSeparatorToken, @@ -100,16 +101,6 @@ function overrideTokenForAgent( return extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], { platform }) } -function expandHomePathToken(token: string, platform: NodeJS.Platform, homeDir: string): string { - if (token === '~') { - return homeDir - } - if (token.startsWith('~/') || (platform === 'win32' && token.startsWith('~\\'))) { - return pathApiForPlatform(platform).join(homeDir, token.slice(2)) - } - return token -} - async function probePathCandidate( candidate: string, dirs: readonly string[], diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 0e9c350c5ece..3dc699d7dfaf 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -504,7 +504,7 @@ describe('caller-supplied launch inputs', () => { expect(h.createStructuredSession).not.toHaveBeenCalled() }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // A custom launch command never routes a launch; native chat runs it as the program. it.each([ ['claude', 'claude-wrapper'], ['codex', 'codex-nightly'] diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 755bd55e2767..85eb2e043c1f 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -297,7 +297,7 @@ async function createSurface( * * 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 + * so the arguments 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 diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 25a9a28653b5..ba5d0e664612 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-process-supervisor' import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { resolveClaudeStructuredInvocation } from './claude-structured-launch-resolution' import type { discoverModelsLocal } from '../text-generation/commit-message-model-discovery' import type { @@ -127,6 +128,19 @@ describe('claude model catalog probe', () => { await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + it('lists nothing, and spawns nothing, when the configured program is not runnable', async () => { + const discover = vi.fn() + const probe = createClaudeModelCatalogProbe({ + ...probeDeps(), + resolveCommand: () => { + throw new AgentSessionPreSpawnError('not runnable', { reason: 'agentCommandNotRunnable' }) + }, + discover + }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(discover).not.toHaveBeenCalled() + }) + it.runIf(process.platform !== 'win32')( 'lists through a supervised one-shot that answers after its input ends', async () => { diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index 87c615d410bb..3b2a63802178 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 { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' const MODEL_ROW = { model: 'gpt-live', @@ -101,4 +102,17 @@ describe('codex model catalog probe', () => { }) await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + + it('lists nothing, and spawns nothing, when the configured program is not runnable', async () => { + const runSession = vi.fn() + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: () => { + throw new AgentSessionPreSpawnError('not runnable', { reason: 'agentCommandNotRunnable' }) + }, + runSession + }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(runSession).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 8acac49cb68c..ab7bdacce941 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -133,6 +133,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'accountSwitchInProgress' | 'managedAccountUnsupported' | 'launchFolderMissing' + | 'agentCommandNotRunnable' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index ccef42f57bdd..def10f73f000 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 @@ -65,6 +65,10 @@ describe('structuredAgentSessionStartFailure', () => { [ 'managedAccountUnsupported', 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], + [ + 'agentCommandNotRunnable', + "The Command set for Claude in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it." ] ] as const)('words a start refused for %s by that situation', (reason, sentence) => { const code = 'agent_session_operation_invalid' 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 6805ffd5eadb..29a864fb598f 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 @@ -27,7 +27,8 @@ const TYPED_START_REFUSALS = [ 'managedAccountEnvOverride', 'accountSwitchInProgress', 'managedAccountUnsupported', - 'launchFolderMissing' + 'launchFolderMissing', + 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] 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 104933eecb76..476dc9008694 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 @@ -136,6 +136,12 @@ describe('a create that fails before any process spawns', () => { 'launchFolderMissing', 'The folder this chat ran in no longer exists. Restore it to continue this chat.' ], + [ + 'a Command setting that names no runnable program', + 'the claude Command setting is not a runnable program: claude-missing', + 'agentCommandNotRunnable', + "The Command set for Claude in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it." + ], [ "Orca's own reason", 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', diff --git a/src/main/native-chat/structured-agent-program.test.ts b/src/main/native-chat/structured-agent-program.test.ts new file mode 100644 index 000000000000..4849727d9904 --- /dev/null +++ b/src/main/native-chat/structured-agent-program.test.ts @@ -0,0 +1,53 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolveStructuredAgentProgram } from './structured-agent-program' + +let root: string +let options: { platform: NodeJS.Platform; pathEnv: string; homePath: string } + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-structured-program-')) + options = { platform: 'linux', pathEnv: join(root, 'bin'), homePath: root } +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function stub(name: string): string { + mkdirSync(join(root, 'bin'), { recursive: true }) + const file = join(root, 'bin', name) + writeFileSync(file, '#!/bin/sh\nexit 0\n') + chmodSync(file, 0o755) + return file +} + +const NOT_RUNNABLE = expect.objectContaining({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' +}) + +describe.skipIf(process.platform === 'win32')('resolveStructuredAgentProgram', () => { + it.each([undefined, null, '', ' '])('runs the stock CLI when the Command is %j', (value) => { + const stock = stub('codex') + expect(resolveStructuredAgentProgram('codex', value, options)).toBe(stock) + }) + + it('runs the configured program', () => { + stub('claude') + const configured = stub('claude-wrapper') + expect(resolveStructuredAgentProgram('claude', 'claude-wrapper', options)).toBe(configured) + }) + + it('refuses a Command that names no runnable program, even with the stock CLI installed', () => { + stub('claude') + expect(() => resolveStructuredAgentProgram('claude', 'claude-missing', options)).toThrow( + NOT_RUNNABLE + ) + expect(() => + resolveStructuredAgentProgram('codex', join(root, 'missing', 'codex'), options) + ).toThrow(NOT_RUNNABLE) + }) +}) diff --git a/src/main/native-chat/structured-agent-program.ts b/src/main/native-chat/structured-agent-program.ts new file mode 100644 index 000000000000..e259162e95c1 --- /dev/null +++ b/src/main/native-chat/structured-agent-program.ts @@ -0,0 +1,29 @@ +import { resolveConfiguredCliProgram } from '../../shared/configured-cli-program' +import { + resolveCliCommand, + type ResolveCommandOptions +} from '../../shared/node-cli-command-resolution' +import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' + +/** + * The program a structured chat (and its model-catalog probe) runs: Settings → Agents → Command + * when set, else the stock CLI. A set Command that is not runnable refuses the launch rather than + * quietly running the stock CLI in its place. + */ +export function resolveStructuredAgentProgram( + agent: 'claude' | 'codex', + configured: string | null | undefined, + options: ResolveCommandOptions = {} +): string { + if (!configured?.trim()) { + return resolveCliCommand(agent, options) + } + const program = resolveConfiguredCliProgram(configured, options) + if (!program) { + throw new AgentSessionPreSpawnError( + `the ${agent} Command setting is not a runnable program: ${configured}`, + { reason: 'agentCommandNotRunnable' } + ) + } + return program +} 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 7ecc214dee70..f17173edb2b3 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,7 +81,7 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { }) }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // A custom launch command never routes a launch; native chat runs it as the program. it.each([ ['claude', 'claude-wrapper'], ['codex', 'codex-nightly'] diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index f299e17b82ee..4417f6d3fda4 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -27,6 +27,7 @@ import { claudeStructuredPermissionModeForSettings } from '../claude/claude-stru import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' import { claudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' +import { resolveStructuredAgentProgram } from '../native-chat/structured-agent-program' export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { async getWorktreePs( @@ -155,6 +156,18 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeLaunchEnv: () => resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), + // Settings → Agents → Command is the program, re-read per acquisition and per catalog probe. + resolveCodexCommand: (options) => + resolveStructuredAgentProgram( + 'codex', + this.requireStore().getSettings().agentCmdOverrides?.codex, + options + ), + resolveClaudeCommand: () => + resolveStructuredAgentProgram( + 'claude', + this.requireStore().getSettings().agentCmdOverrides?.claude + ), // Wired only here, so a test runtime never runs a real `claude --version`. claudeThinkingDisplay: claudeThinkingDisplaySupport, resolveShellEnvironmentPolicy: () => diff --git a/src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts b/src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts new file mode 100644 index 000000000000..cd886f5100c1 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts @@ -0,0 +1,88 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +const installed = vi.hoisted(() => { + const holder: { deps: Partial | null } = { deps: null } + return holder +}) + +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') } +})) + +vi.mock('./structured-agent-session-runtime', () => ({ + ensureStructuredAgentSessionHost: vi.fn( + async (deps: Partial) => { + installed.deps = deps + } + ) +})) + +import { OrcaRuntimeService } from './orca-runtime' + +let root: string +let settings: { agentCmdOverrides: Record } + +beforeEach(() => { + installed.deps = null + root = mkdtempSync(join(tmpdir(), 'orca-runtime-agent-program-')) + settings = { agentCmdOverrides: {} } +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function stub(name: string): string { + mkdirSync(join(root, 'bin'), { recursive: true }) + const file = join(root, 'bin', name) + writeFileSync(file, '#!/bin/sh\nexit 0\n') + chmodSync(file, 0o755) + return file +} + +async function installedDeps(): Promise> { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installing the host reads nothing but store.getSettings(); every other Store member is unreached. + const runtime = new OrcaRuntimeService({ getSettings: () => settings } as never) + await runtime.ensureStructuredAgentSessionHost() + return installed.deps ?? {} +} + +/** The deps feed both the session launch and the model-catalog probe; the runtime class does not + * typecheck its own `this` calls, so pin the settings read behaviourally. */ +describe.skipIf(process.platform === 'win32')('structured agent program wiring', () => { + it('runs the Codex Command found on the launch PATH, re-read per call', async () => { + const deps = await installedDeps() + const wrapper = stub('codex-wrapper') + const pathEnv = join(root, 'bin') + settings.agentCmdOverrides = { codex: 'codex-wrapper' } + expect(deps.resolveCodexCommand?.({ pathEnv, homePath: root })).toBe(wrapper) + settings.agentCmdOverrides = { codex: 'codex-gone' } + expect(() => deps.resolveCodexCommand?.({ pathEnv, homePath: root })).toThrow( + expect.objectContaining({ reason: 'agentCommandNotRunnable' }) + ) + }) + + it('runs the Claude Command, re-read per call', async () => { + const deps = await installedDeps() + const wrapper = stub('claude-wrapper') + settings.agentCmdOverrides = { claude: wrapper } + expect(deps.resolveClaudeCommand?.()).toBe(wrapper) + settings.agentCmdOverrides = { claude: join(root, 'bin', 'claude-gone') } + expect(() => deps.resolveClaudeCommand?.()).toThrow( + expect.objectContaining({ reason: 'agentCommandNotRunnable' }) + ) + }) + + it('keeps the stock lookup when no Command is set', async () => { + const deps = await installedDeps() + const stock = stub('codex') + expect(deps.resolveCodexCommand?.({ pathEnv: join(root, 'bin'), homePath: root })).toBe(stock) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index 93e9bb25a8b1..015fd365ceec 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -87,7 +87,7 @@ describe('a structured default this dispatch cannot honour', () => { expect(decide({ params: { agent: 'codex', worktree: 'current' } }).mode).toBe('structured') }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // A custom launch command never routes a launch; native chat runs it as the program. it.each([ ['claude', 'claude-wrapper'], ['codex', 'codex-nightly'] diff --git a/src/main/runtime/structured-agent-program-threading.test.ts b/src/main/runtime/structured-agent-program-threading.test.ts new file mode 100644 index 000000000000..e875ddb2ba8a --- /dev/null +++ b/src/main/runtime/structured-agent-program-threading.test.ts @@ -0,0 +1,115 @@ +// The program a host resolves for each agent reaches both consumers: the session launch and the +// session-less model-catalog probe. One resolver for both is what keeps a probe from listing a +// different binary's models than the session it stands in for. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as CodexLaunch from '../codex/codex-structured-launch-resolution' +import type * as ClaudeLaunch from '../claude/claude-structured-launch-resolution' +import type * as CodexProbe from '../codex/codex-model-catalog-probe' +import type * as ClaudeProbe from '../claude/claude-model-catalog-probe' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +const seen = vi.hoisted(() => ({ + codexLaunch: new Array(), + claudeLaunch: new Array(), + codexProbe: new Array(), + claudeProbe: new Array() +})) + +function resolveCommandOf(deps: unknown): unknown { + return deps && typeof deps === 'object' && 'resolveCommand' in deps + ? deps.resolveCommand + : undefined +} + +vi.mock('../codex/codex-structured-launch-resolution', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCodexStructuredLaunchResolver: (deps: CodexLaunch.CodexStructuredLaunchResolverDeps) => { + seen.codexLaunch.push(resolveCommandOf(deps)) + return actual.createCodexStructuredLaunchResolver(deps) + } + } +}) + +vi.mock('../claude/claude-structured-launch-resolution', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createClaudeStructuredLaunchResolver: ( + deps: ClaudeLaunch.ClaudeStructuredLaunchResolverDeps + ) => { + seen.claudeLaunch.push(resolveCommandOf(deps)) + return actual.createClaudeStructuredLaunchResolver(deps) + } + } +}) + +vi.mock('../codex/codex-model-catalog-probe', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCodexModelCatalogProbe: (deps: CodexProbe.CodexModelCatalogProbeDeps) => { + seen.codexProbe.push(resolveCommandOf(deps)) + return actual.createCodexModelCatalogProbe(deps) + } + } +}) + +vi.mock('../claude/claude-model-catalog-probe', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createClaudeModelCatalogProbe: (deps: ClaudeProbe.ClaudeModelCatalogProbeDeps) => { + seen.claudeProbe.push(resolveCommandOf(deps)) + return actual.createClaudeModelCatalogProbe(deps) + } + } +}) + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-program-threading-')) + seen.codexLaunch.length = 0 + seen.claudeLaunch.length = 0 + seen.codexProbe.length = 0 + seen.claudeProbe.length = 0 +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + await rm(root, { recursive: true, force: true }) +}) + +describe('the resolved agent program', () => { + it('reaches the session launch and the catalog probe for both agents', async () => { + const resolveCodexCommand = (): string => '/configured/codex-wrapper' + const resolveClaudeCommand = (): string => '/configured/claude-wrapper' + await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveAgentAccountHome: async () => ({ variable: 'CODEX_HOME', path: root }), + resolveCodexCommand, + resolveClaudeCommand + }) + + expect(seen.codexLaunch).toEqual([resolveCodexCommand]) + expect(seen.codexProbe).toEqual([resolveCodexCommand]) + expect(seen.claudeLaunch).toEqual([resolveClaudeCommand]) + expect(seen.claudeProbe).toEqual([resolveClaudeCommand]) + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 6913d3abc7c8..b3421b3742d8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -92,6 +92,8 @@ export type StructuredAgentSessionRuntimeDeps = { /** Key id this host's claims are minted under. */ claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise + /** The program sessions and catalog probes run: this host's configured Command, else the stock + * CLI. Absent is the stock lookup. */ resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx new file mode 100644 index 000000000000..d9ba69734915 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' +import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' + +afterEach(cleanup) + +it('names the Command setting when it is not a program Orca can run', () => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason: 'agentCommandNotRunnable' } + }) + render( + {}} + /> + ) + expect( + screen.getByText( + "Chat could not be started. The Command set for Claude in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it." + ) + ).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 3475db76333e..03214af3e4a3 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -83,6 +83,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.agentCommandNotRunnable', + COPY.agentCommandNotRunnable, + values + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index b4520a225c01..ba15e6521747 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -127,7 +127,8 @@ export async function discoverNativeChatCatalogModels( const hostCatalogAgent = agent === 'claude' ? ('claude' as const) : agent === 'codex' ? ('codex' as const) : null // Only `local` proves a native pane: a paired runtime's key also covers its SSH/WSL worktrees. - // A custom launch command runs a binary the structured catalog never lists; the CLI listing honors it. + // The host catalog models a custom launch command as a program only; the CLI listing honors the + // whole command line a terminal runs. if ( hostCatalogAgent && hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY && diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 0390376562de..986f30dfccdf 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2795,6 +2795,7 @@ "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "agentCommandNotRunnable": "The Command set for {{agent}} in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it.", "noTurnToStop": "{{agent}} had no turn running to stop.", "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 414d24797116..7c17208ab362 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17782,6 +17782,7 @@ "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "agentCommandNotRunnable": "The Command set for {{agent}} in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 30ad9367ae69..c6f9796d3f52 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17558,6 +17558,7 @@ "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", "launchFolderMissing": "La carpeta en la que se ejecutaba este chat ya no existe. Restáurela para continuar este chat.", + "agentCommandNotRunnable": "El comando configurado para {{agent}} en Ajustes → Agentes no es un programa que Orca pueda ejecutar. Indique la ruta o el nombre de un programa, o bórrelo.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 18780b14c81f..6e968c7b03e2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17657,6 +17657,7 @@ "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", "launchFolderMissing": "Le dossier dans lequel ce chat s'exécutait n'existe plus. Restaurez-le pour poursuivre ce chat.", + "agentCommandNotRunnable": "La commande définie pour {{agent}} dans Paramètres → Agents n'est pas un programme qu'Orca peut exécuter. Indiquez le chemin ou le nom d'un programme, ou effacez-la.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3ba56e7d2f1b..293410438cf0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17593,6 +17593,7 @@ "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", "launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。", + "agentCommandNotRunnable": "[設定] → [Agent] で {{agent}} に設定されたコマンドは、Orca で実行できるプログラムではありません。プログラムのパスまたは名前を設定するか、消去してください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6a7fdd244c7c..3ef3b34eb645 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17593,6 +17593,7 @@ "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.", "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", "launchFolderMissing": "이 채팅이 실행되던 폴더가 더 이상 존재하지 않습니다. 이 채팅을 계속하려면 폴더를 복원하세요.", + "agentCommandNotRunnable": "설정 → 에이전트에서 {{agent}}에 지정한 명령은 Orca에서 실행할 수 있는 프로그램이 아닙니다. 프로그램 경로나 이름으로 설정하거나 지우세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index eeaf83edd004..4ba51e5e6cb8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17558,6 +17558,7 @@ "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。", "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", "launchFolderMissing": "此聊天运行所在的文件夹已不存在。请恢复该文件夹以继续此聊天。", + "agentCommandNotRunnable": "在设置 → 智能体中为 {{agent}} 设置的命令不是 Orca 可以运行的程序。请将其设置为程序路径或名称,或将其清空。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 030a8fff2a00..83e77fd4912f 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -260,7 +260,7 @@ describe('buildAgentLaunchRouteInput', () => { expect(input.startsOutsideWorkspaceRoot).toBe(true) }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // A custom launch command never routes a launch; native chat runs it as the program. it.each([ ['claude', 'claude-wrapper'], ['codex', 'codex-nightly'] @@ -411,7 +411,7 @@ describe('buildAgentLaunchRouteInput', () => { } }) - // A launch command override applies to terminal launches only, here or on the server. + // A launch command override never routes a launch, here or on the server. it("does not route on this machine's launch command override for the server", () => { const settings = { ...STRUCTURED_SETTINGS, agentCmdOverrides: { claude: 'claude-wrapper' } } expect( diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index dad67996d4a9..dc003a79d540 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -68,11 +68,11 @@ export type AgentLaunchIntent = { * caller explicitly wants none, and collapsing the two would make a recipe that clears its args * silently inherit whatever the settings happen to hold. * - * Deliberately NOT a route input. Like the configured launch command, arguments are a terminal - * concern: structured chat drives Claude through the Agent SDK and Codex through app-server, whose - * option sets are versioned independently of the interactive CLI's. So args reaching a structured - * launch are ignored rather than forcing a terminal — the host says so in `warning` instead of - * quietly honouring neither the args nor the preference. + * Deliberately NOT a route input. Arguments are a terminal concern: structured chat drives Claude + * through the Agent SDK and Codex through app-server, whose option sets are versioned + * independently of the interactive CLI's. So args reaching a structured launch are ignored rather + * than forcing a terminal — the host says so in `warning` instead of quietly honouring neither the + * args nor the preference. */ agentArgs?: string | null /** diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 18b6fcd46503..20091e94d271 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -32,6 +32,8 @@ export const AGENT_SESSION_FAILURE_COPY = { 'While a Claude account is added in WSL, Claude chats need a Windows Claude account.', launchFolderMissing: 'The folder this chat ran in no longer exists. Restore it to continue this chat.', + agentCommandNotRunnable: + "The Command set for {{agent}} in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 4659ecd560f1..f927b69a21e1 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -210,6 +210,8 @@ const FAILURE_SENTENCES = { managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'), accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'), launchFolderMissing: (_context, _fact, _surface, say) => say('launchFolderMissing'), + agentCommandNotRunnable: (context, _fact, _surface, say) => + say('agentCommandNotRunnable', agent(say, context)), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 62c3bcbf3668..a6db5c60621b 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -24,6 +24,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'accountSwitchInProgress', 'managedAccountUnsupported', 'launchFolderMissing', + 'agentCommandNotRunnable', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index d1a31601fdb2..b1e71a362c6e 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -53,6 +53,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'managedAccountUnsupported', /** A floating chat resumes only in the folder it ran in, and that folder is gone. */ 'launchFolderMissing', + /** Settings → Agents → Command names no program this host can run. */ + 'agentCommandNotRunnable', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index d8756a3c9ec5..d6c1b0354fbe 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -97,6 +97,7 @@ const REASON_WORDS = { accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, launchFolderMissing: { fact: 'launchFolderMissing', action: 'actFirst' }, + agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/configured-cli-program.test.ts b/src/shared/configured-cli-program.test.ts new file mode 100644 index 000000000000..6d5d7c225aa2 --- /dev/null +++ b/src/shared/configured-cli-program.test.ts @@ -0,0 +1,117 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolveConfiguredCliProgram } from './configured-cli-program' + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-configured-program-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function stub(relativePath: string, mode = 0o755): string { + const file = join(root, relativePath) + mkdirSync(join(file, '..'), { recursive: true }) + writeFileSync(file, '#!/bin/sh\nexit 0\n') + chmodSync(file, mode) + return file +} + +function resolveOn( + configured: string, + platform: NodeJS.Platform = 'linux', + pathEnv = join(root, 'bin') +): string | null { + return resolveConfiguredCliProgram(configured, { platform, pathEnv, homePath: root }) +} + +describe.skipIf(process.platform === 'win32')('resolveConfiguredCliProgram', () => { + it('returns an absolute runnable path as it is', () => { + const program = stub('tools/agent-wrapper') + expect(resolveOn(program)).toBe(program) + }) + + it('expands a leading ~ against the launch home', () => { + const program = stub('.local/agent/claude') + expect(resolveOn('~/.local/agent/claude')).toBe(program) + }) + + it.each([`"`, `'`])('strips one surrounding pair of %s quotes', (quote) => { + const program = stub('Agent Tools/claude') + expect(resolveOn(` ${quote}${program}${quote} `)).toBe(program) + }) + + it('looks a bare name up on the launch PATH', () => { + const program = stub('bin/claude-nightly') + expect(resolveOn('claude-nightly')).toBe(program) + }) + + it('finds a bare name in the install directories when PATH misses it', () => { + const program = stub('.bun/bin/codex-nightly') + expect(resolveOn('codex-nightly')).toBe(program) + }) + + it('answers null for a missing program instead of the bare name', () => { + expect(resolveOn('claude-nightly')).toBeNull() + expect(resolveOn(join(root, 'missing/claude'))).toBeNull() + }) + + it('answers null for a file that is not executable', () => { + expect(resolveOn(stub('tools/claude', 0o644))).toBeNull() + }) + + it('answers null for a directory', () => { + mkdirSync(join(root, 'tools/claude'), { recursive: true }) + expect(resolveOn(join(root, 'tools/claude'))).toBeNull() + }) + + it('answers null for a relative path rather than guessing its base', () => { + stub('tools/claude') + expect(resolveOn('./tools/claude')).toBeNull() + expect(resolveOn('tools/claude')).toBeNull() + }) + + it('answers null for a command line, which is not a program', () => { + const program = stub('bin/claude') + expect(resolveOn(`${program} --flag`)).toBeNull() + expect(resolveOn('claude --flag')).toBeNull() + }) + + it('answers null for an empty or quote-only value', () => { + expect(resolveOn(' ')).toBeNull() + expect(resolveOn('""')).toBeNull() + }) + + describe('on Windows', () => { + it('finds the spawnable sibling of an extensionless path', () => { + stub('npm/claude') + const shim = stub('npm/claude.cmd') + expect(resolveOn(join(root, 'npm/claude'), 'win32')).toBe(shim) + }) + + it('refuses an extensionless file Windows cannot spawn', () => { + expect(resolveOn(stub('npm/claude'), 'win32')).toBeNull() + }) + + it('skips an extensionless PATH match for a spawnable one later on PATH', () => { + stub('git-bin/claude') + const exe = stub('bin/claude.exe') + expect( + resolveOn('claude', 'win32', [join(root, 'git-bin'), join(root, 'bin')].join(':')) + ).toBe(exe) + }) + + it.each(['claude.exe', 'claude.com', 'claude.cmd', 'claude.bat'])( + 'accepts an explicit %s path', + (name) => { + const program = stub(`tools/${name}`) + expect(resolveOn(program, 'win32')).toBe(program) + } + ) + }) +}) diff --git a/src/shared/configured-cli-program.ts b/src/shared/configured-cli-program.ts new file mode 100644 index 000000000000..61db2918e3d1 --- /dev/null +++ b/src/shared/configured-cli-program.ts @@ -0,0 +1,64 @@ +import { homedir } from 'node:os' +import path from 'node:path' +import { + WINDOWS_SPAWNABLE_EXTENSION, + findCliExecutable, + getExecutableNames, + isRunnableCommand, + type ResolveCommandOptions +} from './node-cli-command-resolution' + +export function expandHomePathToken( + token: string, + platform: NodeJS.Platform, + homeDir: string +): string { + if (token === '~') { + return homeDir + } + if (token.startsWith('~/') || (platform === 'win32' && token.startsWith('~\\'))) { + return (platform === 'win32' ? path.win32 : path.posix).join(homeDir, token.slice(2)) + } + return token +} + +function withoutSurroundingQuotes(value: string): string { + const quote = value[0] + return value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote) + ? value.slice(1, -1) + : value +} + +/** + * The program a user configured (a path or a name) as an absolute runnable path, found the way the + * stock CLI is. Null when nothing runnable matches; never the bare name, so a caller cannot spawn + * something the user did not pick. + */ +export function resolveConfiguredCliProgram( + configured: string, + options: ResolveCommandOptions = {} +): string | null { + const platform = options.platform ?? process.platform + // Why one quote pair: the same setting is typed into a shell for terminal launches, where a path + // with spaces needs them. + const program = expandHomePathToken( + withoutSurroundingQuotes(configured.trim()), + platform, + options.homePath ?? homedir() + ) + if (!program) { + return null + } + // Why: Windows cannot start an extensionless file, and the spawn would fail with no useful error. + const names = getExecutableNames(platform, program).filter( + (name) => platform !== 'win32' || WINDOWS_SPAWNABLE_EXTENSION.test(name) + ) + if (!program.includes('/') && !(platform === 'win32' && program.includes('\\'))) { + return findCliExecutable(names, { ...options, platform }) + } + // A relative path has no one base directory a launch could honestly resolve it against. + if (!(platform === 'win32' ? path.win32 : path.posix).isAbsolute(program)) { + return null + } + return names.find((name) => isRunnableCommand(platform, name)) ?? null +} diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index c7407ac5eb13..d4a45b742b08 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -3,14 +3,20 @@ import { homedir } from 'node:os' import { delimiter, dirname, isAbsolute, join } from 'node:path' import { getSystemCliInstallDirectories } from './system-cli-install-dirs' -type ResolveCommandOptions = { +export type ResolveCommandOptions = { pathEnv?: string | null platform?: NodeJS.Platform homePath?: string } -function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { +/** What Orca's spawn can start on Windows: `.exe`/`.com` directly, `.cmd`/`.bat` as shims. */ +export const WINDOWS_SPAWNABLE_EXTENSION = /\.(exe|com|cmd|bat)$/i + +export function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { if (platform === 'win32') { + if (WINDOWS_SPAWNABLE_EXTENSION.test(commandName)) { + return [commandName] + } return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] } @@ -71,7 +77,7 @@ function findFirstExecutable( return null } -function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { +export function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { try { const stats = statSync(candidate) if (!stats.isFile()) { @@ -257,12 +263,12 @@ function getCliInstallDirectories(platform: NodeJS.Platform, homePath: string): ] } -export function resolveCliCommand( - commandName: string, +/** The first runnable of `executableNames` on PATH, then in the install directories; null if none. */ +export function findCliExecutable( + executableNames: string[], options: ResolveCommandOptions = {} -): string { +): string | null { const platform = options.platform ?? process.platform - const executableNames = getExecutableNames(platform, commandName) const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) if (pathCandidate) { @@ -270,12 +276,19 @@ export function resolveCliCommand( } const homePath = options.homePath ?? homedir() - const installCandidate = findFirstExecutable( + return findFirstExecutable( platform, getCliInstallDirectories(platform, homePath), executableNames ) - return installCandidate ?? commandName +} + +export function resolveCliCommand( + commandName: string, + options: ResolveCommandOptions = {} +): string { + const platform = options.platform ?? process.platform + return findCliExecutable(getExecutableNames(platform, commandName), options) ?? commandName } export function resolveCliCommands( diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index b02e1a66fe41..47a85d02adb0 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -91,6 +91,7 @@ const KIND_CATEGORY = { accountSwitchInProgress: 'startFailed', managedAccountUnsupported: 'startFailed', launchFolderMissing: 'startFailed', + agentCommandNotRunnable: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -117,6 +118,7 @@ const KIND_VERDICT = { accountSwitchInProgress: 'failure', managedAccountUnsupported: 'failure', launchFolderMissing: 'failure', + agentCommandNotRunnable: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index e1627c305dc4..38cc9adbca56 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -30,7 +30,7 @@ export type StructuredNativeChatBlocker = | 'reused-terminal' | 'agent-without-structured-session' /** The launch names a start directory outside its workspace, which only a PTY can apply. The - * configured launch command and arguments are not read: they apply to terminal launches only. */ + * configured launch command and arguments are not read: neither decides the surface. */ | 'custom-start-directory' | 'remote-execution-host' | 'project-runtime' diff --git a/src/shared/tui-agent-launch-command-override.ts b/src/shared/tui-agent-launch-command-override.ts index 8bc21ea395ed..45dab57dbf76 100644 --- a/src/shared/tui-agent-launch-command-override.ts +++ b/src/shared/tui-agent-launch-command-override.ts @@ -2,11 +2,11 @@ import type { GlobalSettings } from './global-settings-types' import type { TuiAgent } from './tui-agent' /** - * Whether the user replaced this agent's launch command for terminal launches. + * Whether the user replaced this agent's launch command. * - * The command applies only where the CLI runs in a terminal; structured native chat ignores it and - * never routes on it. Terminal-backed chat reads it to skip the structured model catalog, which - * lists the stock binary rather than the one this terminal runs. + * Never a route input: a terminal types it as a command line, and structured native chat runs it as + * the program. Terminal-backed chat reads it to skip the structured model catalog, which models + * only that program, not the whole command line this terminal runs. */ export function hasExplicitTuiLaunchCommand( settings: Partial> | null | undefined, From d2570c52fc99c86f097c27570bff14509df4f2b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:24:53 -0700 Subject: [PATCH 4/7] fix(native-chat): find a configured Claude Command on the launch PATH; clearer copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Claude Command given as a bare name was looked up on Orca's own PATH, not the PATH the Claude child launches with (login shell plus Settings → Agents environment), so a name only that environment provides was refused. Codex already resolved against its launch environment. Claude now reads its launch env first and resolves a configured name against its PATH and home; the stock lookup with no Command is unchanged. The failure copy now states the rule (a program path or name, no arguments or variables) and names the Settings control (Reset). The catalog fingerprint doc notes that the program is not keyed, so a chat on an older program keeps refreshing the shared entry until it ends. --- .../claude-structured-launch-resolution.ts | 12 ++++++-- src/main/codex-cli/command.test.ts | 9 ++++++ .../agent-model-catalog-fingerprint.ts | 4 ++- ...uctured-agent-session-failure-text.test.ts | 2 +- ...ent-session-pre-spawn-first-answer.test.ts | 2 +- .../runtime/orca-runtime-get-worktree-ps.ts | 12 +++++--- ...me-structured-agent-program-wiring.test.ts | 30 +++++++++++++++++++ .../structured-agent-session-runtime.ts | 2 +- .../structured-claude-runtime-adapter.ts | 5 ++-- ...veChatLaunchRetry.command-failure.test.tsx | 17 ++++++++++- .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/agent-session-failure-copy.ts | 2 +- src/shared/node-cli-command-resolution.ts | 8 +++++ 19 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index dc33d652e4de..e6143ff02d0a 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -11,7 +11,7 @@ import { } from '../../shared/agent-session-provider-handle' import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { pathEnvOf, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, @@ -115,7 +115,8 @@ export type ClaudeStructuredLaunch = { export type ClaudeStructuredLaunchResolverDeps = { store: Pick resolveWorkspacePath: (workspaceId: string) => Promise - resolveCommand?: () => string + /** Given the PATH and home the child launches with; absent is the stock lookup on Orca's PATH. */ + resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveEnv?: () => | Promise | undefined> | Record @@ -171,11 +172,16 @@ export type ClaudeChildEnvSources = { export async function resolveClaudeChildEnvSources( deps: ClaudeEnvDeps ): Promise { - const command = (deps.resolveCommand ?? resolveClaudeCommand)() const overlay = await deps.resolveEnv?.() const inheritedEnv = deps.resolveInheritedEnv ? await deps.resolveInheritedEnv() : cloneDefinedEnv(process.env) + // Resolved against the env the child launches with, as the overlay spreads last in claudeChildEnv. + const launchEnv = { ...inheritedEnv, ...overlay } + const homePath = launchEnv.HOME ?? launchEnv.USERPROFILE + const command = deps.resolveCommand + ? deps.resolveCommand({ pathEnv: pathEnvOf(launchEnv), ...(homePath ? { homePath } : {}) }) + : resolveClaudeCommand() return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv } } diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index dbe1c358418a..ba0b63ddbbf9 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -4,6 +4,7 @@ import { delimiter, dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getVersionManagerBinPaths, + pathEnvOf, withCliRuntimeOnPath, resolveClaudeCommand, resolveCliCommands, @@ -390,3 +391,11 @@ describe('withCliRuntimeOnPath', () => { expect(paired.Path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';')) }) }) + +describe('pathEnvOf', () => { + it('reads the first case-insensitive PATH entry on Windows, as the child does', () => { + expect(pathEnvOf({ Path: 'C:\\first', PATH: 'C:\\second' }, 'win32')).toBe('C:\\first') + expect(pathEnvOf({ PATH: '/usr/bin', Path: '/ignored' }, 'linux')).toBe('/usr/bin') + expect(pathEnvOf({}, 'win32')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index d59f0d124c33..10fd70b9612c 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -10,7 +10,9 @@ import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stor * Everything that changes which models a listing can answer with: the agent, * the account home the CLI reads credentials/config from, and the execution * host that runs the binary. Login-state or CLI-version drift under the same - * key is corrected by the next refresh, never by the fingerprint. + * key is corrected by the next refresh, never by the fingerprint. The program + * is not keyed either: after a Command change, a chat still running the old + * program keeps refreshing this entry with its list until it ends. */ export type AgentModelCatalogIdentity = { agent: string 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 def10f73f000..f88be4b6cb73 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 @@ -68,7 +68,7 @@ describe('structuredAgentSessionStartFailure', () => { ], [ 'agentCommandNotRunnable', - "The Command set for Claude in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it." + "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." ] ] as const)('words a start refused for %s by that situation', (reason, sentence) => { const code = 'agent_session_operation_invalid' 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 476dc9008694..d7343e8411ed 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 @@ -140,7 +140,7 @@ describe('a create that fails before any process spawns', () => { 'a Command setting that names no runnable program', 'the claude Command setting is not a runnable program: claude-missing', 'agentCommandNotRunnable', - "The Command set for Claude in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it." + "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." ], [ "Orca's own reason", diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4417f6d3fda4..d9faffb780c0 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -163,11 +163,15 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis this.requireStore().getSettings().agentCmdOverrides?.codex, options ), - resolveClaudeCommand: () => - resolveStructuredAgentProgram( + resolveClaudeCommand: (options) => { + const configured = this.requireStore().getSettings().agentCmdOverrides?.claude + // Stock Claude keeps its lookup on Orca's own PATH; a configured name uses the launch PATH. + return resolveStructuredAgentProgram( 'claude', - this.requireStore().getSettings().agentCmdOverrides?.claude - ), + configured, + configured?.trim() ? options : {} + ) + }, // Wired only here, so a test runtime never runs a real `claude --version`. claudeThinkingDisplay: claudeThinkingDisplaySupport, resolveShellEnvironmentPolicy: () => diff --git a/src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts b/src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts index cd886f5100c1..40c513b8450d 100644 --- a/src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-program-wiring.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' +import { resolveClaudeChildEnvSources } from '../claude/claude-structured-launch-resolution' +import { resolveClaudeCommand } from '../codex-cli/command' const installed = vi.hoisted(() => { const holder: { deps: Partial | null } = { deps: null } @@ -84,5 +86,33 @@ describe.skipIf(process.platform === 'win32')('structured agent program wiring', const deps = await installedDeps() const stock = stub('codex') expect(deps.resolveCodexCommand?.({ pathEnv: join(root, 'bin'), homePath: root })).toBe(stock) + stub('claude') + // Stock Claude still resolves on Orca's own PATH, not the launch PATH it is handed. + expect(deps.resolveClaudeCommand?.({ pathEnv: join(root, 'bin'), homePath: root })).toBe( + resolveClaudeCommand() + ) + }) + + it.each([ + ['a name only the Claude agent env puts on PATH', 'my-claude', undefined], + ['exactly `claude`, found on the launch PATH', 'claude', join('bin')] + ])('finds a configured Claude Command given as %s', async (_, name, inheritedBin) => { + const deps = await installedDeps() + const resolveCommand = deps.resolveClaudeCommand + if (!resolveCommand) { + throw new Error('the runtime installed no Claude program resolver') + } + const program = stub(name) + settings.agentCmdOverrides = { claude: name } + const sources = await resolveClaudeChildEnvSources({ + resolveCommand, + resolveInheritedEnv: async () => ({ + PATH: inheritedBin ? join(root, inheritedBin) : join(root, 'empty'), + HOME: root + }), + // Settings → Agents environment for Claude, which spreads over the inherited env. + resolveEnv: (): Record => (inheritedBin ? {} : { PATH: join(root, 'bin') }) + }) + expect(sources.command).toBe(program) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b3421b3742d8..4634e1b038e0 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -95,7 +95,7 @@ export type StructuredAgentSessionRuntimeDeps = { /** The program sessions and catalog probes run: this host's configured Command, else the stock * CLI. Absent is the stock lookup. */ resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string - resolveClaudeCommand?: () => string + resolveClaudeCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ claudeThinkingDisplay?: ClaudeThinkingDisplaySupport /** Provider transports are overridden only to drive the runtime against scripted children. */ diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index cb25f94db7c8..cd1c8df09521 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,7 +1,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' import { @@ -24,7 +23,7 @@ import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-dis export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise - resolveClaudeCommand?: () => string + resolveClaudeCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ claudeThinkingDisplay?: ClaudeThinkingDisplaySupport resolveClaudeLaunchEnv?: () => Promise> | Record @@ -87,7 +86,7 @@ export function createStructuredClaudeRuntimeAdapter( resolveLaunch: createClaudeStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, - resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, + ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), ...(deps.resolveClaudeInheritedEnv ? { resolveInheritedEnv: deps.resolveClaudeInheritedEnv } diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx index d9ba69734915..74faee5fcff8 100644 --- a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment happy-dom import { cleanup, render, screen } from '@testing-library/react' import { afterEach, expect, it } from 'vitest' +import { sayAgentSessionFailureEnglish } from '../../../../shared/agent-session-failure-copy' +import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' @@ -21,8 +23,21 @@ it('names the Command setting when it is not a program Orca can run', () => { ) expect( screen.getByText( - "Chat could not be started. The Command set for Claude in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it." + "Chat could not be started. Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." ) ).toBeTruthy() expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() }) + +it('reads naturally where no agent name is known', () => { + expect( + agentSessionFailureSentence( + { kind: 'agentCommandNotRunnable' }, + 'row', + {}, + sayAgentSessionFailureEnglish + ) + ).toBe( + "The agent's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." + ) +}) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 986f30dfccdf..0d8556137139 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2795,7 +2795,7 @@ "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", - "agentCommandNotRunnable": "The Command set for {{agent}} in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it.", + "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "noTurnToStop": "{{agent}} had no turn running to stop.", "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7c17208ab362..1224f32bf0bc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17782,7 +17782,7 @@ "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", - "agentCommandNotRunnable": "The Command set for {{agent}} in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it.", + "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c6f9796d3f52..7d1d16fb4eb0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17558,7 +17558,7 @@ "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", "launchFolderMissing": "La carpeta en la que se ejecutaba este chat ya no existe. Restáurela para continuar este chat.", - "agentCommandNotRunnable": "El comando configurado para {{agent}} en Ajustes → Agentes no es un programa que Orca pueda ejecutar. Indique la ruta o el nombre de un programa, o bórrelo.", + "agentCommandNotRunnable": "{{agent}} requiere que su Comando en Ajustes → Agentes sea la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6e968c7b03e2..d8833659f3b5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17657,7 +17657,7 @@ "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", "launchFolderMissing": "Le dossier dans lequel ce chat s'exécutait n'existe plus. Restaurez-le pour poursuivre ce chat.", - "agentCommandNotRunnable": "La commande définie pour {{agent}} dans Paramètres → Agents n'est pas un programme qu'Orca peut exécuter. Indiquez le chemin ou le nom d'un programme, ou effacez-la.", + "agentCommandNotRunnable": "{{agent}} exige que sa Commande dans Paramètres → Agents soit le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 293410438cf0..2e8f303c9dff 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17593,7 +17593,7 @@ "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", "launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。", - "agentCommandNotRunnable": "[設定] → [Agent] で {{agent}} に設定されたコマンドは、Orca で実行できるプログラムではありません。プログラムのパスまたは名前を設定するか、消去してください。", + "agentCommandNotRunnable": "{{agent}} の [設定] → [Agent] の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3ef3b34eb645..bc35910ff488 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17593,7 +17593,7 @@ "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.", "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", "launchFolderMissing": "이 채팅이 실행되던 폴더가 더 이상 존재하지 않습니다. 이 채팅을 계속하려면 폴더를 복원하세요.", - "agentCommandNotRunnable": "설정 → 에이전트에서 {{agent}}에 지정한 명령은 Orca에서 실행할 수 있는 프로그램이 아닙니다. 프로그램 경로나 이름으로 설정하거나 지우세요.", + "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4ba51e5e6cb8..3f67644e8e3c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17558,7 +17558,7 @@ "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。", "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", "launchFolderMissing": "此聊天运行所在的文件夹已不存在。请恢复该文件夹以继续此聊天。", - "agentCommandNotRunnable": "在设置 → 智能体中为 {{agent}} 设置的命令不是 Orca 可以运行的程序。请将其设置为程序路径或名称,或将其清空。", + "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 20091e94d271..34bb594aa9e6 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -33,7 +33,7 @@ export const AGENT_SESSION_FAILURE_COPY = { launchFolderMissing: 'The folder this chat ran in no longer exists. Restore it to continue this chat.', agentCommandNotRunnable: - "The Command set for {{agent}} in Settings → Agents isn't a program Orca can run. Set it to a program path or name, or clear it.", + "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index d4a45b742b08..3a254db89d17 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -336,6 +336,14 @@ function firstWindowsPathEnvKey(env: NodeJS.ProcessEnv): string { return 'Path' } +/** The PATH a child launched with `env` reads: on Windows, the first case-insensitive entry. */ +export function pathEnvOf( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform +): string | null { + return env[platform === 'win32' ? firstWindowsPathEnvKey(env) : 'PATH'] ?? null +} + /** * Put a resolved CLI's own directory ahead of PATH when that directory ships a * sibling `node`. From f12ae257f7c64098d31e333492dcb1937ff9896a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:38:14 -0700 Subject: [PATCH 5/7] fix(native-chat): one PATH key for the Claude lookup and the child on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, an inherited `Path` and a Settings → Agents env `PATH` both reached the Claude child. The configured-program lookup read the first inserted twin (inherited), while Node's child_process keeps the lexicographically first (`PATH`, the user's), so a bare-name Command found only on the Settings PATH was refused. The Claude launch env now drops inherited case-twins of the overlay's variables (the rule the structured shell env already applies), so the lookup and the child read one PATH. pathEnvOf follows Node's win32 key rule, and Codex's lookup uses it too, so both agents read PATH one way. es/fr/ja copy now says the Command must be, as the English does. --- ...laude-structured-launch-resolution.test.ts | 26 +++++++++++++++++++ .../claude-structured-launch-resolution.ts | 6 +++-- src/main/codex-cli/command.test.ts | 13 +++++++--- .../codex-structured-launch-resolution.ts | 3 ++- .../structured-agent-shell-environment.ts | 20 ++++++++++---- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/shared/node-cli-command-resolution.ts | 7 +++-- 9 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index c280553cfe52..797606119cd9 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -14,6 +14,7 @@ import { CLAUDE_STRUCTURED_BASE_OPTIONS, claudeSessionIdForOrcaSession, createClaudeStructuredLaunchResolver, + resolveClaudeStructuredInvocation, type ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' @@ -618,3 +619,28 @@ describe('readable Claude thinking', () => { } }) }) + +describe('the PATH a configured Claude program is found on', () => { + it('is the one PATH a Windows child gets when Settings env and the shell spell it differently', async () => { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const resolveCommand = vi.fn(() => 'C:\\tools\\my-claude.exe') + const { env } = await resolveClaudeStructuredInvocation({ + resolveCommand, + resolveInheritedEnv: async () => ({ Path: 'C:\\inherited', USERPROFILE: 'C:\\Users\\me' }), + resolveEnv: () => ({ PATH: 'C:\\overlay;C:\\inherited' }), + resolveAuthPolicy: () => ({ stripAuthEnv: false }) + }) + expect(resolveCommand).toHaveBeenCalledWith({ + pathEnv: 'C:\\overlay;C:\\inherited', + homePath: 'C:\\Users\\me' + }) + // No twin left for Node to choose between: the child reads the PATH the lookup used. + expect(Object.keys(env).filter((name) => name.toUpperCase() === 'PATH')).toEqual(['PATH']) + expect(env.PATH).toBe('C:\\overlay;C:\\inherited') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } + }) +}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index e6143ff02d0a..69ee1f749498 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -31,6 +31,7 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { resolveClaudeCommand } from '../codex-cli/command' +import { withoutOverlaidVariables } from '../runtime/structured-agent-shell-environment' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' @@ -173,10 +174,11 @@ export async function resolveClaudeChildEnvSources( deps: ClaudeEnvDeps ): Promise { const overlay = await deps.resolveEnv?.() - const inheritedEnv = deps.resolveInheritedEnv + const inherited = deps.resolveInheritedEnv ? await deps.resolveInheritedEnv() : cloneDefinedEnv(process.env) - // Resolved against the env the child launches with, as the overlay spreads last in claudeChildEnv. + // Twin-free, so the lookup below and the child (overlay spread last in claudeChildEnv) read one PATH. + const inheritedEnv = withoutOverlaidVariables(inherited, overlay ?? {}, process.platform) const launchEnv = { ...inheritedEnv, ...overlay } const homePath = launchEnv.HOME ?? launchEnv.USERPROFILE const command = deps.resolveCommand diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index ba0b63ddbbf9..9c4a909bd76e 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -393,9 +393,16 @@ describe('withCliRuntimeOnPath', () => { }) describe('pathEnvOf', () => { - it('reads the first case-insensitive PATH entry on Windows, as the child does', () => { - expect(pathEnvOf({ Path: 'C:\\first', PATH: 'C:\\second' }, 'win32')).toBe('C:\\first') - expect(pathEnvOf({ PATH: '/usr/bin', Path: '/ignored' }, 'linux')).toBe('/usr/bin') + // Node's child_process on win32 sorts env keys and keeps the first of each case-insensitive twin. + it('reads the PATH twin a Windows child keeps, whatever the insertion order', () => { + expect(pathEnvOf({ Path: 'C:\\inherited', PATH: 'C:\\overlay' }, 'win32')).toBe('C:\\overlay') + expect(pathEnvOf({ path: 'C:\\lower', Path: 'C:\\mixed' }, 'win32')).toBe('C:\\mixed') + expect(pathEnvOf({ Path: 'C:\\only' }, 'win32')).toBe('C:\\only') expect(pathEnvOf({}, 'win32')).toBeNull() }) + + it('reads only PATH elsewhere', () => { + expect(pathEnvOf({ Path: '/ignored', PATH: '/usr/bin' }, 'linux')).toBe('/usr/bin') + expect(pathEnvOf({ Path: '/ignored' }, 'linux')).toBeNull() + }) }) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 723281c590aa..679b2b67e14e 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -10,6 +10,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' +import { pathEnvOf } from '../../shared/node-cli-command-resolution' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' @@ -48,7 +49,7 @@ export async function resolveCodexStructuredInvocation( deps: Pick ): Promise { const environment = await deps.resolveEnvironment?.() - const pathEnv = environment?.PATH ?? environment?.Path ?? null + const pathEnv = environment ? pathEnvOf(environment) : null const homePath = environment?.HOME ?? environment?.USERPROFILE const command = (deps.resolveCommand ?? resolveCodexCommand)({ pathEnv, diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 7233772445a6..e33a19256e7a 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -17,22 +17,32 @@ function definedEnv(env: NodeJS.ProcessEnv): Record { return defined } -/** Overlays shell variables on Orca's env; win32 names are case-insensitive, so drop the base spelling. */ -function overlayShellVariables( +/** `base` without what `overlay` replaces: win32 names are case-insensitive, so a differently + * cased twin goes too, rather than sitting beside the overlay's spelling for Node to pick between. */ +export function withoutOverlaidVariables( base: Record, - shellVariables: Record, + overlay: Record, platform: NodeJS.Platform ): Record { const merged = { ...base } if (platform === 'win32') { - const overlaid = new Set(Object.keys(shellVariables).map((key) => key.toUpperCase())) + const overlaid = new Set(Object.keys(overlay).map((key) => key.toUpperCase())) for (const key of Object.keys(merged)) { if (overlaid.has(key.toUpperCase())) { delete merged[key] } } } - return { ...merged, ...shellVariables } + return merged +} + +/** Overlays shell variables on Orca's env, dropping the base spelling of each. */ +function overlayShellVariables( + base: Record, + shellVariables: Record, + platform: NodeJS.Platform +): Record { + return { ...withoutOverlaidVariables(base, shellVariables, platform), ...shellVariables } } function pickShellVariables( diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7d1d16fb4eb0..ec71fb22360a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17558,7 +17558,7 @@ "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", "launchFolderMissing": "La carpeta en la que se ejecutaba este chat ya no existe. Restáurela para continuar este chat.", - "agentCommandNotRunnable": "{{agent}} requiere que su Comando en Ajustes → Agentes sea la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", + "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8833659f3b5..86c9dc913ccc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17657,7 +17657,7 @@ "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", "launchFolderMissing": "Le dossier dans lequel ce chat s'exécutait n'existe plus. Restaurez-le pour poursuivre ce chat.", - "agentCommandNotRunnable": "{{agent}} exige que sa Commande dans Paramètres → Agents soit le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", + "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2e8f303c9dff..b4aa481aa6e6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17593,7 +17593,7 @@ "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", "launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。", - "agentCommandNotRunnable": "{{agent}} の [設定] → [Agent] の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", + "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index 3a254db89d17..e537fc2feeb6 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -336,12 +336,15 @@ function firstWindowsPathEnvKey(env: NodeJS.ProcessEnv): string { return 'Path' } -/** The PATH a child launched with `env` reads: on Windows, the first case-insensitive entry. */ +/** The PATH a `child_process` child launched with `env` reads. On Windows Node keeps only the + * lexicographically first of case-insensitive twins (`PATH` before `Path`), not the first inserted. */ export function pathEnvOf( env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform ): string | null { - return env[platform === 'win32' ? firstWindowsPathEnvKey(env) : 'PATH'] ?? null + const twins = + platform === 'win32' ? Object.keys(env).filter((name) => name.toUpperCase() === 'PATH') : [] + return env[twins.sort()[0] ?? 'PATH'] ?? null } /** From a480d3211bcfe1c34376aeaa0bf964a3d15d002f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:03:20 -0700 Subject: [PATCH 6/7] fix(native-chat): refuse a Command that can't run instead of running the stock CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A saved Settings → Agents → Command that did not resolve to a program (a command line such as `codex --profile work`, `FOO=1 claude` or `npx …`, a missing or relative path, a non-executable file) silently ran the stock Claude/Codex in native chat and its model-catalog probe. The user's choice was ignored with no sign of it. A set Command that does not resolve now refuses the start with a new agentCommandNotRunnable failure naming the setting, beside the existing Retry; the catalog probe refuses the same way and never lists the stock CLI. A blank Command keeps the stock lookup. On Windows a resolved file without a spawnable extension (.exe/.com/.cmd/.bat) refuses too, instead of failing to spawn with no word about the setting. --- .../claude/claude-model-catalog-probe.test.ts | 14 ++- .../codex/codex-model-catalog-probe.test.ts | 23 ++++- .../structured-agent-session-adapter.ts | 1 + ...uctured-agent-session-failure-text.test.ts | 4 + .../structured-agent-session-failure-text.ts | 3 +- ...ent-session-pre-spawn-first-answer.test.ts | 6 ++ ...tructured-agent-command-resolution.test.ts | 91 ++++++++++++++++++- .../structured-agent-command-resolution.ts | 27 +++++- ...-structured-launch-settings-wiring.test.ts | 6 +- ...veChatLaunchRetry.command-failure.test.tsx | 43 +++++++++ .../agent-session-failure-words-text.ts | 6 ++ .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + ...ctured-agent-session-dispatch-rejection.ts | 2 + 24 files changed, 227 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 3f7792b33de6..e77d63d90b05 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -59,8 +59,8 @@ function listedResult(): DiscoverResult { } describe('claude model catalog probe', () => { - it.each([`"${process.execPath}"`, 'npx claude', '/missing/claude', './claude'])( - 'lists with the session executable for Command %s', + it.each([`"${process.execPath}"`, ''])( + 'lists with the session executable for Command %j', async (command) => { const deps = probeDeps(command) const captured: DiscoverInput[] = [] @@ -134,6 +134,16 @@ describe('claude model catalog probe', () => { await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + it.each(['npx claude', 'claude --verbose', '/missing/claude', './claude'])( + 'lists nothing, and never probes the stock CLI, for Command %s', + async (command) => { + const discover = vi.fn() + const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), discover }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(discover).not.toHaveBeenCalled() + } + ) + it.runIf(process.platform !== 'win32')( 'lists through a supervised one-shot that answers after its input ends', async () => { diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index d7f3edff47ba..a3aeeebc4af0 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -13,8 +13,8 @@ const MODEL_ROW = { } describe('codex model catalog probe', () => { - it.each([`"${process.execPath}"`, 'npx codex', '/missing/codex', './codex'])( - 'lists with the session executable for Command %s', + it.each([`"${process.execPath}"`, ''])( + 'lists with the session executable for Command %j', async (command) => { // The env a user's shell/config resolves for sessions, PATH included. const resolveEnvironment = async (): Promise => ({ @@ -111,4 +111,23 @@ describe('codex model catalog probe', () => { }) await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + + it.each(['npx codex', 'codex --profile work', '/missing/codex', './codex'])( + 'lists nothing, and never probes the stock CLI, for Command %s', + async (command) => { + const runSession = vi.fn() + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: (options) => + resolveStructuredAgentCommand( + 'codex', + { agentCmdOverrides: { codex: command } }, + options + ), + runSession + }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(runSession).not.toHaveBeenCalled() + } + ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 8acac49cb68c..ab7bdacce941 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -133,6 +133,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'accountSwitchInProgress' | 'managedAccountUnsupported' | 'launchFolderMissing' + | 'agentCommandNotRunnable' > /** Acquisition failed with first-hand proof that no provider process existed. */ 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 b508cc9e7571..c49b79b06e64 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 @@ -99,6 +99,10 @@ describe('structuredAgentSessionStartFailure', () => { [ 'managedAccountUnsupported', 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], + [ + 'agentCommandNotRunnable', + "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." ] ] as const)('words a start refused for %s by that situation', (reason, sentence) => { const code = 'agent_session_operation_invalid' 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 75f7769e7a7c..a48848fb7176 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 @@ -29,7 +29,8 @@ const TYPED_START_REFUSALS = [ 'managedAccountEnvOverride', 'accountSwitchInProgress', 'managedAccountUnsupported', - 'launchFolderMissing' + 'launchFolderMissing', + 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] 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 e6c3cead9313..fab401d351c0 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 @@ -163,6 +163,12 @@ describe('a create that fails before any process spawns', () => { 'launchFolderMissing', 'The folder this chat ran in no longer exists. Restore it to continue this chat.' ], + [ + 'a Command setting that names no runnable program', + 'the claude Command setting is not a runnable program: claude-missing', + 'agentCommandNotRunnable', + "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." + ], [ "Orca's own reason", 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts index 3fa758cfe5b6..a2fc166a1a33 100644 --- a/src/main/native-chat/structured-agent-command-resolution.test.ts +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { resolveStructuredAgentCommand } from './structured-agent-command-resolution' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +const NOT_RUNNABLE = expect.objectContaining({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' +}) + const scratch: string[] = [] afterEach(() => { for (const dir of scratch.splice(0)) { @@ -62,7 +67,12 @@ describe('structured agent executable resolution', () => { expect(resolveStructuredAgentCommand('claude', settings)).toBe(second.command) }) - it('uses the stock executable for missing files, directories, relative paths and shell lines', () => { + it.each(['', ' ', undefined])('uses the stock executable when the Command is %j', (command) => { + const settings = command === undefined ? {} : { agentCmdOverrides: { claude: command } } + expect(resolveStructuredAgentCommand('claude', settings)).toBe(resolveCliCommand('claude')) + }) + + it('refuses missing files, directories, relative paths and command lines instead of the stock executable', () => { const { directory } = executable() const folder = join(directory, 'folder') mkdirSync(folder) @@ -71,18 +81,89 @@ describe('structured agent executable resolution', () => { folder, './claude', 'npx claude', - 'wrapper --flag' + 'wrapper --flag', + 'FOO=1 claude', + '$HOME/bin/claude' ]) { const settings = { agentCmdOverrides: { claude: command } } - expect(resolveStructuredAgentCommand('claude', settings)).toBe(resolveCliCommand('claude')) + expect(() => resolveStructuredAgentCommand('claude', settings)).toThrow(NOT_RUNNABLE) } }) it.skipIf(process.platform === 'win32')('requires executable permission on Unix', () => { const { command } = executable() chmodSync(command, 0o644) - expect( + expect(() => resolveStructuredAgentCommand('claude', { agentCmdOverrides: { claude: `"${command}"` } }) - ).toBe(resolveCliCommand('claude')) + ).toThrow(NOT_RUNNABLE) + }) + + it('keeps the saved value out of the refusal, since a command line can carry a secret', () => { + expect(() => + resolveStructuredAgentCommand('codex', { + agentCmdOverrides: { codex: 'TOKEN=sk-secret codex' } + }) + ).toThrow(expect.not.objectContaining({ message: expect.stringContaining('sk-secret') })) + }) + + describe('on Windows', () => { + function fileIn(directory: string, name: string): string { + const file = join(directory, name) + writeFileSync(file, '') + return file + } + + it('refuses an extensionless path, which Windows cannot spawn', () => { + const { directory } = executable() + const command = fileIn(directory, 'claude') + fileIn(directory, 'claude.cmd') + expect(() => + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: command } }, + { platform: 'win32' } + ) + ).toThrow(NOT_RUNNABLE) + }) + + it('refuses a name whose only match on PATH is extensionless', () => { + const { directory } = executable() + fileIn(directory, 'my-claude') + expect(() => + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: 'my-claude' } }, + { platform: 'win32', pathEnv: directory, homePath: directory } + ) + ).toThrow(NOT_RUNNABLE) + }) + + it.each(['claude.exe', 'claude.com', 'claude.cmd', 'claude.bat'])( + 'runs an explicit %s path', + (name) => { + const { directory } = executable() + const command = fileIn(directory, name) + expect( + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: `"${command}"` } }, + { platform: 'win32' } + ) + ).toBe(command) + } + ) + + it('finds the .cmd shim for a bare name, as the stock lookup does', () => { + const { directory } = executable() + fileIn(directory, 'my-claude') + const shim = fileIn(directory, 'my-claude.cmd') + expect( + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: 'my-claude' } }, + { platform: 'win32', pathEnv: directory, homePath: directory } + ) + ).toBe(shim) + }) }) }) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 7ce9afcb77b1..9cc1f28bd010 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -4,7 +4,11 @@ import { resolveCliCommand, resolveExecutableCommand } from '../../shared/node-cli-command-resolution' -import { structuredAgentCommandToken } from '../../shared/tui-agent-launch-command-override' +import { + hasExplicitTuiLaunchCommand, + structuredAgentCommandToken +} from '../../shared/tui-agent-launch-command-override' +import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' type CommandSettings = Partial> type CommandOptions = NonNullable[1]> @@ -24,11 +28,28 @@ function resolveOverride( : null } -/** Re-read the existing setting for every session acquisition and catalog probe. */ +// Windows starts .exe/.com directly and .cmd/.bat through Orca's shim handling; an extensionless +// file would fail to spawn with no word about the setting. +function spawnableOn(platform: NodeJS.Platform, command: string): boolean { + return platform !== 'win32' || /\.(exe|com|cmd|bat)$/i.test(command) +} + +/** Re-read the existing setting for every session acquisition and catalog probe. A set Command + * that names no runnable program refuses the start rather than quietly running the stock CLI. */ export function resolveStructuredAgentCommand( agent: 'claude' | 'codex', settings: CommandSettings, options: CommandOptions = {} ): string { - return resolveOverride(agent, settings, options) ?? resolveCliCommand(agent, options) + if (!hasExplicitTuiLaunchCommand(settings, agent)) { + return resolveCliCommand(agent, options) + } + const command = resolveOverride(agent, settings, options) + if (!command || !spawnableOn(options.platform ?? process.platform, command)) { + // The value stays out of the message: a saved command line can carry a secret. + throw new AgentSessionPreSpawnError(`the ${agent} Command setting is not a runnable program`, { + reason: 'agentCommandNotRunnable' + }) + } + return command } 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 index e9738615b5f8..dd81e303b41d 100644 --- a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts @@ -37,9 +37,13 @@ describe('execution host structured launch settings wiring', () => { settings.agentDefaultArgs = { claude: '--model second', codex: '' } expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'second']) expect(await deps?.resolveLaunchArgs?.('codex')).toEqual([]) + const notRunnable = expect.objectContaining({ reason: 'agentCommandNotRunnable' }) settings.agentCmdOverrides = { claude: 'wrapper --arg' } - expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) + expect(() => deps?.resolveClaudeCommand?.()).toThrow(notRunnable) settings.agentCmdOverrides = { codex: '/missing/codex' } + expect(() => deps?.resolveCodexCommand?.()).toThrow(notRunnable) + settings.agentCmdOverrides = {} + expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) expect(deps?.resolveCodexCommand?.()).toBe(resolveCliCommand('codex')) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx new file mode 100644 index 000000000000..74faee5fcff8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.command-failure.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { sayAgentSessionFailureEnglish } from '../../../../shared/agent-session-failure-copy' +import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' +import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' +import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' + +afterEach(cleanup) + +it('names the Command setting when it is not a program Orca can run', () => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason: 'agentCommandNotRunnable' } + }) + render( + {}} + /> + ) + expect( + screen.getByText( + "Chat could not be started. Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." + ) + ).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) + +it('reads naturally where no agent name is known', () => { + expect( + agentSessionFailureSentence( + { kind: 'agentCommandNotRunnable' }, + 'row', + {}, + sayAgentSessionFailureEnglish + ) + ).toBe( + "The agent's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." + ) +}) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 2c455769a78a..0fa34b19aaab 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -108,6 +108,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.agentCommandNotRunnable', + COPY.agentCommandNotRunnable, + values + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 7a0ed36def40..c721158c7da1 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2796,6 +2796,7 @@ "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "noTurnToStop": "{{agent}} had no turn running to stop.", "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e79ad7a56bca..ac496dded1e7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17790,6 +17790,7 @@ "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", + "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4a0ffda0913b..0677121d325c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17565,6 +17565,7 @@ "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", "launchFolderMissing": "La carpeta en la que se ejecutaba este chat ya no existe. Restáurela para continuar este chat.", + "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 120650649cf3..cc1365452fa1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17666,6 +17666,7 @@ "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", "launchFolderMissing": "Le dossier dans lequel ce chat s'exécutait n'existe plus. Restaurez-le pour poursuivre ce chat.", + "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 764a792f0422..ae6268134222 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17600,6 +17600,7 @@ "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", "launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。", + "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7c5525ae7a5d..bca38d0f89b6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17600,6 +17600,7 @@ "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.", "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", "launchFolderMissing": "이 채팅이 실행되던 폴더가 더 이상 존재하지 않습니다. 이 채팅을 계속하려면 폴더를 복원하세요.", + "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 59c73df48b43..0545b3fdbc86 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17565,6 +17565,7 @@ "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。", "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", "launchFolderMissing": "此聊天运行所在的文件夹已不存在。请恢复该文件夹以继续此聊天。", + "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index a55978aea293..4575485b456b 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -37,6 +37,8 @@ export const AGENT_SESSION_FAILURE_COPY = { 'While a Claude account is added in WSL, Claude chats need a Windows Claude account.', launchFolderMissing: 'The folder this chat ran in no longer exists. Restore it to continue this chat.', + agentCommandNotRunnable: + "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index abb601ff1b9e..b8dacdfd8136 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -220,6 +220,8 @@ const FAILURE_SENTENCES = { managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'), accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'), launchFolderMissing: (_context, _fact, _surface, say) => say('launchFolderMissing'), + agentCommandNotRunnable: (context, _fact, _surface, say) => + say('agentCommandNotRunnable', agent(say, context)), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index fe3180be6c8a..fe69dd2243e5 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -30,6 +30,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'accountSwitchInProgress', 'managedAccountUnsupported', 'launchFolderMissing', + 'agentCommandNotRunnable', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index f296e42df357..b9ef64f2c470 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -57,6 +57,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'managedAccountUnsupported', /** A floating chat resumes only in the folder it ran in, and that folder is gone. */ 'launchFolderMissing', + /** Settings → Agents → Command names no program this host can run. */ + 'agentCommandNotRunnable', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index d8756a3c9ec5..d6c1b0354fbe 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -97,6 +97,7 @@ const REASON_WORDS = { accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, launchFolderMissing: { fact: 'launchFolderMissing', action: 'actFirst' }, + agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index a67b76cd3b81..69bd7760aea4 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -91,6 +91,7 @@ const KIND_CATEGORY = { accountSwitchInProgress: 'startFailed', managedAccountUnsupported: 'startFailed', launchFolderMissing: 'startFailed', + agentCommandNotRunnable: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -117,6 +118,7 @@ const KIND_VERDICT = { accountSwitchInProgress: 'failure', managedAccountUnsupported: 'failure', launchFolderMissing: 'failure', + agentCommandNotRunnable: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', From 8e48f0f9665d22417a59eef8060fb4beb7306e58 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:13:06 -0700 Subject: [PATCH 7/7] chore(native-chat): correct comments that say native chat ignores the Command After #25721 native chat runs a runnable Command, and this PR refuses one it can't run; four comments still said the Command applies to terminal launches only. The pre-spawn test fixture also drops the saved value from its error message, matching the resolver. --- .../structured-agent-session-pre-spawn-first-answer.test.ts | 2 +- .../runtime/rpc/methods/orchestration-worker-start-mode.test.ts | 2 +- .../native-chat/native-chat-session-option-discovery.ts | 2 +- src/renderer/src/lib/agent-launch-route-input.test.ts | 2 +- src/shared/structured-native-chat-launch-route.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 fab401d351c0..7dfd624b496d 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 @@ -165,7 +165,7 @@ describe('a create that fails before any process spawns', () => { ], [ 'a Command setting that names no runnable program', - 'the claude Command setting is not a runnable program: claude-missing', + 'the claude Command setting is not a runnable program', 'agentCommandNotRunnable', "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." ], diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index 93e9bb25a8b1..aee3bfefb6d0 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -87,7 +87,7 @@ describe('a structured default this dispatch cannot honour', () => { expect(decide({ params: { agent: 'codex', worktree: 'current' } }).mode).toBe('structured') }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // A custom launch command never decides the surface; native chat runs it as the program. it.each([ ['claude', 'claude-wrapper'], ['codex', 'codex-nightly'] diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index b4520a225c01..ac43cd4c74be 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -127,7 +127,7 @@ export async function discoverNativeChatCatalogModels( const hostCatalogAgent = agent === 'claude' ? ('claude' as const) : agent === 'codex' ? ('codex' as const) : null // Only `local` proves a native pane: a paired runtime's key also covers its SSH/WSL worktrees. - // A custom launch command runs a binary the structured catalog never lists; the CLI listing honors it. + // Terminal-backed chat runs the full custom command line, which only the CLI listing models. if ( hostCatalogAgent && hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY && diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 47738bde69b0..8a1ac92a224a 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -415,7 +415,7 @@ describe('buildAgentLaunchRouteInput', () => { } }) - // A launch command override applies to terminal launches only, here or on the server. + // A launch command override never decides the surface, here or on the server. it("does not route on this machine's launch command override for the server", () => { const settings = { ...STRUCTURED_SETTINGS, agentCmdOverrides: { claude: 'claude-wrapper' } } expect( diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index e1627c305dc4..f92bac17e847 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -30,7 +30,7 @@ export type StructuredNativeChatBlocker = | 'reused-terminal' | 'agent-without-structured-session' /** The launch names a start directory outside its workspace, which only a PTY can apply. The - * configured launch command and arguments are not read: they apply to terminal launches only. */ + * configured launch command and arguments never decide the surface. */ | 'custom-start-directory' | 'remote-execution-host' | 'project-runtime'