Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/main/claude/claude-model-catalog-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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 () => {
Expand Down
23 changes: 21 additions & 2 deletions src/main/codex/codex-model-catalog-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeJS.ProcessEnv> => ({
Expand Down Expand Up @@ -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()
}
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export type AgentSessionPreSpawnReason = Extract<
| 'accountSwitchInProgress'
| 'managedAccountUnsupported'
| 'launchFolderMissing'
| 'agentCommandNotRunnable'
>

/** Acquisition failed with first-hand proof that no provider process existed. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const TYPED_START_REFUSALS = [
'managedAccountEnvOverride',
'accountSwitchInProgress',
'managedAccountUnsupported',
'launchFolderMissing'
'launchFolderMissing',
'agentCommandNotRunnable'
] as const satisfies readonly (AgentSessionFailureKind &
AgentSessionRefusalReason<'agent_session_operation_invalid'>)[]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
'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',
Expand Down
91 changes: 86 additions & 5 deletions src/main/native-chat/structured-agent-command-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
})
})
})
27 changes: 24 additions & 3 deletions src/main/native-chat/structured-agent-command-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultEnv'>>
type CommandOptions = NonNullable<Parameters<typeof resolveExecutableCommand>[1]>
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<NativeChatLaunchRetry
lifecycle="failed"
failure={failure}
agentLabel="Claude"
onRetry={() => {}}
/>
)
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."
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopy
'components.native-chat.failureWords.launchFolderMissing',
COPY.launchFolderMissing
),
agentCommandNotRunnable: (values) =>
translate(
'components.native-chat.failureWords.agentCommandNotRunnable',
COPY.agentCommandNotRunnable,
values
),
chooseClaudeAccount: () =>
translate(
'components.native-chat.failureWords.chooseClaudeAccount',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/i18n/en-runtime-required.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -17600,6 +17600,7 @@
"accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。",
"managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。",
"launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。",
"agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。",
"chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。",
"chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。",
"chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。",
Expand Down
Loading
Loading