diff --git a/src/main/acp/acp-dialects/grok-launch-args.test.ts b/src/main/acp/acp-dialects/grok-launch-args.test.ts new file mode 100644 index 000000000000..8504f03320d8 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-launch-args.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { StructuredAgentArgumentsError } from '../../native-chat/structured-agent-arguments-error' +import { grokAgentArgv } from './grok-launch-args' + +const argv = (configured: readonly string[], fullAccess = false): string[] => + grokAgentArgv({ fullAccess, configured }) + +describe('grokAgentArgv', () => { + it('keeps the root options `grok agent` reads before `agent`, as typed', () => { + expect( + argv([ + '--debug', + '--debug-file', + '/tmp/grok.log', + '--leader-socket=/tmp/leader.sock', + '--disable-web-search', + '--no-auto-update', + '--compaction-mode=auto', + '--compaction-detail', + 'brief', + '--log-sampling' + ]) + ).toEqual([ + '--debug', + '--debug-file', + '/tmp/grok.log', + '--leader-socket=/tmp/leader.sock', + '--disable-web-search', + '--no-auto-update', + '--compaction-mode=auto', + '--compaction-detail', + 'brief', + '--log-sampling', + 'agent', + 'stdio' + ]) + }) + + // `grok agent` ignores the root model, effort and leader options; its own take them. + it('moves model, effort and --no-leader to the agent options', () => { + expect(argv(['-mgrok-4', '--effort', 'high', '--reasoning-effort=low', '--no-leader'])).toEqual( + [ + 'agent', + '--model=grok-4', + '--reasoning-effort', + 'high', + '--reasoning-effort=low', + '--no-leader', + 'stdio' + ] + ) + expect(argv(['--model', 'grok-4'], true)).toEqual([ + 'agent', + '--model', + 'grok-4', + '--always-approve', + 'stdio' + ]) + }) + + // Grok's parser reads a separate `-x` as an option, never a value; joined, it's the value. + it('keeps a joined value joined, so one that starts with a dash stays a value', () => { + expect(argv(['--debug-file=-grok.log', '-m-x', '--compaction-mode=-y'])).toEqual([ + '--debug-file=-grok.log', + '--compaction-mode=-y', + 'agent', + '--model=-x', + 'stdio' + ]) + }) + + it('drops permission, terminal-only and session options with their values', () => { + expect( + argv([ + '--permission-mode', + 'bypassPermissions', + '--always-approve', + '--yolo', + '--dangerously-skip-permissions', + '--oauth', + '--force-login', + '--allow', + 'Bash(git:*)', + '--allowedTools=Edit', + '--deny', + 'Bash(rm:*)', + '--disallowedTools', + 'Write', + '--sandbox', + 'strict', + '--trust', + '--no-alt-screen', + '--minimal', + '--fullscreen', + '-h', + '-V', + '--resume', + 'abc', + '-r', + '-c', + '--session-id=s1', + '-s', + 's2', + '--fork-session', + '--load', + 's3', + '-p', + 'hello', + '--output-format', + 'json', + '--verbatim' + ]) + ).toEqual(['agent', 'stdio']) + }) + + it.each([ + { configured: ['fix the private tests'], option: 'prompt', problem: 'positionalPrompt' }, + { configured: ['--debug', '--', 'private'], option: 'prompt', problem: 'positionalPrompt' }, + { configured: ['-'], option: 'prompt', problem: 'positionalPrompt' }, + { configured: ['--cwd', '/private'], option: '--cwd', problem: 'unsupportedOption' }, + { configured: ['-w'], option: '-w', problem: 'unsupportedOption' }, + { configured: ['--worktree=private'], option: '--worktree', problem: 'unsupportedOption' }, + { configured: ['--ref', 'private'], option: '--ref', problem: 'unsupportedOption' }, + { configured: ['--leader'], option: '--leader', problem: 'unsupportedOption' }, + { configured: ['--rules', 'private'], option: '--rules', problem: 'unsupportedOption' }, + { + configured: ['--system-prompt', 'private'], + option: '--system-prompt', + problem: 'unsupportedOption' + }, + { configured: ['--tools', 'private'], option: '--tools', problem: 'unsupportedOption' }, + { configured: ['--future=private'], option: '--future', problem: 'unsupportedOption' }, + { configured: ['--debug=private'], option: '--debug', problem: 'unsupportedOption' }, + { configured: ['-m', 'a', '--model', 'private'], option: '--model', problem: 'multipleValues' }, + { configured: ['-m'], option: '-m', problem: 'missingValue' }, + { configured: ['--debug-file', '--debug'], option: '--debug-file', problem: 'missingValue' } + ] as const)('refuses what a chat cannot honor: %j', ({ configured, option, problem }) => { + const thrown = () => argv(configured) + expect(thrown).toThrow(StructuredAgentArgumentsError) + try { + thrown() + } catch (error) { + expect(error).toMatchObject({ argumentProblem: { agent: 'Grok', option, problem } }) + for (const text of [JSON.stringify(error), String(error)]) { + expect(text).not.toContain('private') + } + } + }) +}) diff --git a/src/main/acp/acp-dialects/grok-launch-args.ts b/src/main/acp/acp-dialects/grok-launch-args.ts new file mode 100644 index 000000000000..382a57a338ed --- /dev/null +++ b/src/main/acp/acp-dialects/grok-launch-args.ts @@ -0,0 +1,194 @@ +// Saved Arguments are written for the interactive `grok` a terminal runs; a chat runs +// `grok [root options] agent [agent options] stdio`, and `agent` reads only a few root options. So +// each option either stays before `agent`, moves to the `agent` option it has there, is dropped +// because it sets permissions, belongs to a terminal or to the session Orca owns, or refuses the +// start by name because a chat can't honor it. +import { StructuredAgentArgumentsError } from '../../native-chat/structured-agent-arguments-error' + +type LaunchPlan = { root: string[]; agent: string[]; models: number } + +type Apply = (plan: LaunchPlan, option: string, value: string, joined: boolean) => void + +type OptionRule = { takesValue: boolean; apply?: Apply } | 'refuse' + +/** A value written joined (`--opt=value`) stays joined: Grok's parser takes a separate value only + * when it doesn't start with `-`. */ +function spelled(name: string, value: string, joined: boolean): string[] { + if (!value) { + return [name] + } + return joined ? [`${name}=${value}`] : [name, value] +} + +const root: Apply = (plan, option, value, joined) => { + plan.root.push(...spelled(option, value, joined)) +} + +const agentOption = + (name: string): Apply => + (plan, _option, value, joined) => { + plan.agent.push(...spelled(name, value, joined)) + } + +const model: Apply = (plan, option, value, joined) => { + // `agent --model` takes one value; a second is the parser's error in a terminal too. + if (++plan.models > 1) { + throw new StructuredAgentArgumentsError('Grok', option, 'multipleValues') + } + agentOption('--model')(plan, option, value, joined) +} + +const VALUE = { takesValue: true } +const SWITCH = { takesValue: false } +const ROOT_VALUE = { takesValue: true, apply: root } +const ROOT_SWITCH = { takesValue: false, apply: root } +const EFFORT = { takesValue: true, apply: agentOption('--reasoning-effort') } + +// From grok's own parser (xai-grok-pager src/app/cli.rs); `grok agent` honors only the root +// options kept here (xai-grok-pager-bin src/main.rs: run_agent_command, and configure_process_env, +// which sets the debug, leader-socket, compaction and log-sampling environment before dispatch). +const RULES: Record = { + '--debug': ROOT_SWITCH, + '--debug-file': ROOT_VALUE, + '--leader-socket': ROOT_VALUE, + '--disable-web-search': ROOT_SWITCH, + '--no-auto-update': ROOT_SWITCH, + '--compaction-mode': ROOT_VALUE, + '--compaction-detail': ROOT_VALUE, + '--log-sampling': ROOT_SWITCH, + '-m': { ...VALUE, apply: model }, + '--model': { ...VALUE, apply: model }, + '--effort': EFFORT, + '--reasoning-effort': EFFORT, + // A root `--no-leader` is an error beside `agent`; the agent takes its own. + '--no-leader': { takesValue: false, apply: agentOption('--no-leader') }, + // A typed permission mode never applies. Full access is Agent Permissions' bypass flag, which it + // keeps in these Arguments and reads on its own; otherwise Grok's own config decides (STA-9763). + '--permission-mode': VALUE, + '--always-approve': SWITCH, + '--yolo': SWITCH, + '--dangerously-skip-permissions': SWITCH, + '--allow': VALUE, + '--allowedTools': VALUE, + '--deny': VALUE, + '--disallowedTools': VALUE, + '--sandbox': VALUE, + '--trust': SWITCH, + '--trust-folder': SWITCH, + // Only shape a terminal. + '--no-alt-screen': SWITCH, + '--minimal': SWITCH, + '--fullscreen': SWITCH, + '-h': SWITCH, + '--help': SWITCH, + '-v': SWITCH, + '-V': SWITCH, + '--version': SWITCH, + // The terminal's own sign-in screen; `grok agent` never reads them. + '--oauth': SWITCH, + '--force-login': SWITCH, + // The session and its output are the chat's own. + '-r': { takesValue: false }, + '--resume': { takesValue: false }, + '--load': VALUE, + '-c': SWITCH, + '--continue': SWITCH, + '-s': VALUE, + '--session-id': VALUE, + '--fork-session': SWITCH, + '--restore-code': SWITCH, + '-p': VALUE, + '--single': VALUE, + '--print': VALUE, + '--prompt-json': VALUE, + '--prompt-file': VALUE, + '--output-format': VALUE, + '--include-partial-messages': SWITCH, + '--json-schema': VALUE, + '--verbatim': SWITCH, + // No chat equivalent: another folder, a shared process, or another prompt or tool set. + '--cwd': 'refuse', + '-w': 'refuse', + '--worktree': 'refuse', + '--worktree-ref': 'refuse', + '--ref': 'refuse', + '--leader': 'refuse', + '--rules': 'refuse', + '--append-system-prompt': 'refuse', + '--system-prompt': 'refuse', + '--system-prompt-override': 'refuse', + '--tools': 'refuse' +} + +/** Takes an optional value, as `--resume [SESSION]` does: only a next token that isn't an option. */ +const OPTIONAL_VALUE = new Set(['-r', '--resume']) + +function optionName(token: string): string { + if (token.startsWith('--')) { + return token.split('=', 1)[0] + } + return /^-[mspr].+/.test(token) ? token.slice(0, 2) : token +} + +// Grok's parser accepts `-m=X` as well as `-mX`. +function inlineValue(token: string, option: string): string | undefined { + if (token === option) { + return undefined + } + return token.startsWith('--') ? token.slice(option.length + 1) : token.slice(2).replace(/^=/, '') +} + +/** Saved terminal Arguments as `grok` argv: what goes before `agent`, and the `agent` options. */ +function grokLaunchArgs(tokens: readonly string[]): { root: string[]; agent: string[] } { + const plan: LaunchPlan = { root: [], agent: [], models: 0 } + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (token === '--') { + // What follows is the terminal's first prompt. + if (index + 1 < tokens.length) { + throw new StructuredAgentArgumentsError('Grok', token, 'positionalPrompt') + } + break + } + if (!token.startsWith('-') || token === '-') { + throw new StructuredAgentArgumentsError('Grok', token, 'positionalPrompt') + } + const option = optionName(token) + const rule = RULES[option] + if (!rule || rule === 'refuse') { + throw new StructuredAgentArgumentsError('Grok', option, 'unsupportedOption') + } + let value = '' + let joined = false + if (rule.takesValue) { + const inline = inlineValue(token, option) + joined = inline !== undefined + value = inline ?? tokens[++index] ?? '' + if (!value || value === '--' || (value.startsWith('-') && inline === undefined)) { + throw new StructuredAgentArgumentsError('Grok', option, 'missingValue') + } + } else if (OPTIONAL_VALUE.has(option)) { + if ( + token === option && + tokens[index + 1] !== undefined && + !tokens[index + 1].startsWith('-') + ) { + index += 1 + } + } else if (token !== option) { + throw new StructuredAgentArgumentsError('Grok', option, 'unsupportedOption') + } + rule.apply?.(plan, option, value, joined) + } + return { root: plan.root, agent: plan.agent } +} + +/** `grok [root] agent [agent] stdio` for a chat: the saved Arguments placed where `grok agent` + * reads them, and full access as the agent's own flag. */ +export function grokAgentArgv(input: { + fullAccess: boolean + configured: readonly string[] +}): string[] { + const { root, agent } = grokLaunchArgs(input.configured) + return [...root, 'agent', ...agent, ...(input.fullAccess ? ['--always-approve'] : []), 'stdio'] +} diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 2b705aa0dd7f..4d6f4689a5c9 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -9,13 +9,15 @@ import { import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import type { AcpDialect } from './acp-dialects/acp-dialect' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { grokAgentArgv } from './acp-dialects/grok-launch-args' export type AcpLaunchSpec = { /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ agent: string command: string - /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ - args(input: { fullAccess: boolean }): string[] + /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture, and + * `configured` the saved Arguments as typed. */ + args(input: { fullAccess: boolean; configured: readonly string[] }): string[] /** Overlaid on the child's environment. */ env: Readonly> dialect: AcpDialect @@ -39,7 +41,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'grok', command: 'grok', // `--always-approve` only for full access, as the user's setting chooses. - args: ({ fullAccess }) => ['agent', ...(fullAccess ? ['--always-approve'] : []), 'stdio'], + args: grokAgentArgv, env: {}, dialect: GROK_ACP_DIALECT, loginCommand: ['grok', 'login'], diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index e39ade6c7b97..243b84fb5cc0 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -172,7 +172,7 @@ export async function openAcpAdapterRig( resolveLaunch: async () => ({ spec: GROK, command: '/opt/grok/bin/grok', - args: GROK.args({ fullAccess: false }), + args: GROK.args({ fullAccess: false, configured: [] }), cwd: '/workspace/project', env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, fullAccess: false, diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts index bf6c7a363c8d..53ed22f582b9 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -85,6 +85,7 @@ async function openRestoreRig(code: number, message: string) { readJournal: (sessionId) => replayJournal(journalDatabase.db, sessionId), resolveWorkspacePath: async () => '/workspace', resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveLaunchArgs: () => [], resolveCommand: () => '/fake/grok' }) const warnings = async () => { diff --git a/src/main/acp/acp-structured-host-start-refusal.test.ts b/src/main/acp/acp-structured-host-start-refusal.test.ts new file mode 100644 index 000000000000..ad2f938a39d2 --- /dev/null +++ b/src/main/acp/acp-structured-host-start-refusal.test.ts @@ -0,0 +1,91 @@ +// A Grok chat whose saved Arguments or Command can't start, driven through the real launch +// resolver and the ACP start path to the refusal a client receives: named, and before any spawn. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' +import { mapRuntimeError } from '../runtime/rpc/errors' +import { GROK } from './acp-structured-adapter.test-support' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' +import { attachParams, openHostRig } from './acp-structured-host.test-support' + +afterEach(async () => { + vi.restoreAllMocks() + await closeProviderTimelineRigs() +}) + +async function attachUnder(launch: { launchArgs: string[]; resolveCommand?: () => string }) { + const resolver: { resolve: ReturnType | null } = { + resolve: null + } + const opened = await openHostRig({ + deps: { + resolveLaunch: (input) => { + if (!resolver.resolve) { + throw new Error('launch resolver not ready') + } + return resolver.resolve(input) + } + } + }) + resolver.resolve = createAcpStructuredLaunchResolver(GROK, { + store: opened.store, + readJournal: (sessionId) => replayJournal(opened.journalDatabase.db, sessionId), + resolveWorkspacePath: async () => '/workspace', + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveLaunchArgs: () => launch.launchArgs, + resolveCommand: launch.resolveCommand ?? (() => '/fake/grok') + }) + const attach = () => + opened.host.attach(CALLER, attachParams()).then( + (result) => result, + (error: unknown) => error + ) + return { first: await attach(), replay: await attach(), spawned: opened.rig.spawned } +} + +describe('a Grok chat that cannot start under its saved settings', () => { + it('refuses saved Arguments it cannot honor by name, on the first answer and the replay', async () => { + const { first, replay, spawned } = await attachUnder({ launchArgs: ['--cwd', '/private'] }) + const sentence = + "Grok couldn't start. Grok chats can't use --cwd from saved Arguments. Remove it from Grok's Arguments in Settings → Agents. A chat already works in its workspace folder. Send your message to try again." + for (const result of [first, replay]) { + expect(result).toMatchObject({ + ok: false, + refusal: { + message: sentence, + details: { + reason: 'attachFailed', + argumentProblem: { agent: 'Grok', option: '--cwd', problem: 'unsupportedOption' } + } + } + }) + } + expect(JSON.stringify([first, replay])).not.toContain('private') + expect(spawned).toEqual([]) + }) + + it('refuses a Command that names no runnable program, in the words its replay reads', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { first, replay, spawned } = await attachUnder({ + launchArgs: [], + resolveCommand: () => { + throw new AgentSessionPreSpawnError('the grok Command setting is not a runnable program', { + reason: 'agentCommandNotRunnable' + }) + } + }) + const sentence = + "Grok'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." + // Thrown, as Claude's and Codex's are: the wire carries the sentence, never the raw message. + for (const result of [first, replay]) { + expect(result).toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, result)).toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: sentence } + }) + } + expect(spawned).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts index 0e7af326a604..9c2fcd66ef01 100644 --- a/src/main/acp/acp-structured-launch-resolution.test.ts +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -58,11 +58,14 @@ function journalWithTurns(...providerSessions: string[]): JournalLoad { function resolver( record: AgentSessionRecord, fullAccess = false, - readJournal: () => JournalLoad | null = () => null + readJournal: () => JournalLoad | null = () => null, + launchArgs: string[] = [] ) { const searched: (string | null | undefined)[] = [] + const argsReadFor: string[] = [] return { searched, + argsReadFor, resolve: createAcpStructuredLaunchResolver(GROK, { store: { getRecord: () => record }, readJournal, @@ -70,9 +73,13 @@ function resolver( resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), resolveLaunchEnv: () => ({ GROK_EXTRA: '1' }), resolveFullAccess: () => fullAccess, - resolveCommand: (command, options) => { + resolveLaunchArgs: (provider) => { + argsReadFor.push(provider) + return launchArgs + }, + resolveCommand: (agent, options) => { searched.push(options?.pathEnv) - return `/resolved/${command}` + return `/resolved/${agent}` } }) } @@ -101,6 +108,20 @@ describe('ACP launch resolution', () => { expect(launch.args).toContain('--always-approve') }) + it("places the chat's saved Arguments where `grok agent` reads them", async () => { + const { resolve, argsReadFor } = resolver(grokRecord(), false, () => null, [ + '--debug', + '-m', + 'grok-4', + '--no-leader', + '--sandbox', + 'strict' + ]) + const launch = await resolve({ identity }) + expect(argsReadFor).toEqual(['grok']) + expect(launch.args).toEqual(['--debug', 'agent', '--model', 'grok-4', '--no-leader', 'stdio']) + }) + it('resumes the chain head by its key, possibly unsaved only when this chat created it', async () => { const link = (origin: 'created' | 'resumed') => ({ linkId: `link-${origin}`, diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index dbe372495a0a..c610284e39df 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -23,6 +23,7 @@ import { spelledProviderTimelineItemKey } from '../native-chat/agent-session-timeline/provider-timeline-identity' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AcpLaunchSpec } from './acp-launch-specs' import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' @@ -58,7 +59,10 @@ export type AcpStructuredLaunchResolverDeps = { resolveLaunchEnv?: (agent: string) => Record /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ resolveFullAccess?: (agent: string) => boolean - resolveCommand?: typeof resolveCliCommand + /** The agent's saved Arguments, re-read per acquisition. */ + resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] + /** The agent's saved Command, else its stock CLI; refuses a Command that names no program. */ + resolveCommand?: (agent: string, options: Parameters[1]) => string homePath?: string } @@ -97,16 +101,20 @@ export function createAcpStructuredLaunchResolver( const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories(accountHome.path)] .filter((entry): entry is string => Boolean(entry)) .join(delimiter) - const command = (deps.resolveCommand ?? resolveCliCommand)(spec.command, { + const commandOptions = { pathEnv, homePath: env.HOME ?? env.USERPROFILE ?? deps.homePath ?? homedir() - }) + } + const command = deps.resolveCommand + ? deps.resolveCommand(spec.agent, commandOptions) + : resolveCliCommand(spec.command, commandOptions) const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false + const configured = await deps.resolveLaunchArgs(record.provider) const head = agentSessionProviderHandleChainHead(record.providerHandleChain) return { spec, command, - args: spec.args({ fullAccess }), + args: spec.args({ fullAccess, configured }), cwd: await deps.resolveWorkspacePath(location.workspaceId), env, fullAccess, diff --git a/src/main/acp/acp-structured-launch-settings.test.ts b/src/main/acp/acp-structured-launch-settings.test.ts new file mode 100644 index 000000000000..d8a2e8e6f5b9 --- /dev/null +++ b/src/main/acp/acp-structured-launch-settings.test.ts @@ -0,0 +1,133 @@ +// A Grok chat's launch under the saved Command and Arguments, composed as the runtime composes it, +// against a real install directory: untouched settings must launch exactly what a chat did before. +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' +import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' + +const GROK = acpLaunchSpecFor('grok')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null +} +const scratch: string[] = [] + +afterEach(() => { + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function executable(path: string): string { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '#!/bin/sh\n') + chmodSync(path, 0o755) + return path +} + +/** A home whose Grok is installed only in `$GROK_HOME/bin`, not on PATH. */ +function grokHome() { + const root = mkdtempSync(join(tmpdir(), 'orca-grok-launch-')) + scratch.push(root) + const grokHomePath = join(root, 'home', '.grok') + return { + root, + grokHomePath, + installed: executable(join(grokHomePath, 'bin', 'grok')), + env: { PATH: join(root, 'empty-bin'), HOME: join(root, 'home') } + } +} + +function launchUnder( + settings: Partial, + home: ReturnType, + withSettings = true +) { + return createAcpStructuredLaunchResolver(GROK, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'grok', + providerHandleChain: [], + accountHome: { variable: 'GROK_HOME', path: home.grokHomePath } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => home.env, + resolveFullAccess: (agent) => + agent === 'grok' && resolvedTuiAgentArgsBypassPermissions('grok', settings, process.platform), + resolveLaunchArgs: () => (withSettings ? structuredAgentConfiguredArgs('grok', settings) : []), + // Without settings: the stock lookup every chat made before the Command was read. + ...(withSettings + ? { + resolveCommand: (agent: string, options: Parameters[1]) => + resolveStructuredAgentCommand(agent, settings, options) + } + : {}) + })({ identity }) +} + +describe.skipIf(process.platform === 'win32')('a Grok chat under its saved Command', () => { + // Agent Permissions is the Arguments field: absent is the shipped bypass default, '' is Manual. + it.each<[string, Partial, string[]]>([ + ['untouched (the shipped default)', {}, ['agent', '--always-approve', 'stdio']], + ['Manual', { agentDefaultArgs: { grok: '' } }, ['agent', 'stdio']] + ])( + 'launches what it did before with a blank Command and %s Arguments', + async (_label, settings, argv) => { + const home = grokHome() + const before = await launchUnder(settings, home, false) + const now = await launchUnder(settings, home) + expect(now.command).toBe(home.installed) + expect(now.command).toBe(before.command) + expect(now.command).toBe( + resolveCliCommand('grok', { + pathEnv: [home.env.PATH, join(home.grokHomePath, 'bin')].join(delimiter), + homePath: home.env.HOME + }) + ) + expect(now.args).toEqual(argv) + expect(now.fullAccess).toBe(argv.includes('--always-approve')) + } + ) + + it('runs a saved Command that names a program, and refuses one that names none', async () => { + const home = grokHome() + const nightly = executable(join(home.root, 'opt', 'grok-nightly')) + await expect( + launchUnder({ agentCmdOverrides: { grok: nightly } }, home) + ).resolves.toMatchObject({ command: nightly }) + await expect( + launchUnder({ agentCmdOverrides: { grok: join(home.root, 'missing', 'grok') } }, home) + ).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + await expect( + launchUnder({ agentCmdOverrides: { grok: 'grok --debug' } }, home) + ).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + }) + + it('refuses saved Arguments a chat cannot honor, by name', async () => { + const home = grokHome() + await expect( + launchUnder({ agentDefaultArgs: { grok: '--cwd /elsewhere' } }, home) + ).rejects.toMatchObject({ + argumentProblem: { agent: 'Grok', option: '--cwd', problem: 'unsupportedOption' } + }) + await expect( + launchUnder({ agentDefaultArgs: { grok: '--model "grok' } }, home) + ).rejects.toMatchObject({ + argumentProblem: { agent: 'Grok', option: 'quote', problem: 'unclosedQuote' } + }) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 3fad79da2362..112dd66b92ea 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -456,8 +456,12 @@ describe('ACP structured session adapter: close and exit', () => { describe('Grok launch spec', () => { it('runs `grok agent stdio`, asking for always-approve only with full access', () => { - expect(GROK.args({ fullAccess: false })).toEqual(['agent', 'stdio']) - expect(GROK.args({ fullAccess: true })).toEqual(['agent', '--always-approve', 'stdio']) + expect(GROK.args({ fullAccess: false, configured: [] })).toEqual(['agent', 'stdio']) + expect(GROK.args({ fullAccess: true, configured: [] })).toEqual([ + 'agent', + '--always-approve', + 'stdio' + ]) expect(GROK.env).toEqual({}) }) }) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 9cc1f28bd010..cbabe8045a9c 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgent } from '../../shared/tui-agent' +import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveCliCommand, resolveExecutableCommand @@ -37,11 +38,11 @@ function spawnableOn(platform: NodeJS.Platform, command: string): boolean { /** 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', + agent: string, settings: CommandSettings, options: CommandOptions = {} ): string { - if (!hasExplicitTuiLaunchCommand(settings, agent)) { + if (!isTuiAgent(agent) || !hasExplicitTuiLaunchCommand(settings, agent)) { return resolveCliCommand(agent, options) } const command = resolveOverride(agent, settings, options) diff --git a/src/main/native-chat/structured-agent-configured-args.ts b/src/main/native-chat/structured-agent-configured-args.ts index 39b564188a4b..805b4b7d9329 100644 --- a/src/main/native-chat/structured-agent-configured-args.ts +++ b/src/main/native-chat/structured-agent-configured-args.ts @@ -1,13 +1,19 @@ -import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' import type { GlobalSettings } from '../../shared/global-settings-types' import { resolveTuiAgentLaunchArgs } from '../../shared/tui-agent-launch-defaults' import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { StructuredAgentArgumentsError } from './structured-agent-arguments-error' +import type { AgentSessionArgumentProblem } from '../../shared/agent-session-argument-problem' + +const AGENT_NAMES = { + claude: 'Claude', + codex: 'Codex', + grok: 'Grok' +} as const satisfies Record /** Use the same grouping rules as terminal launches before the provider filters its owned flags. */ export function structuredAgentConfiguredArgs( - agent: AgentSessionHandleProvider, + agent: keyof typeof AGENT_NAMES, settings: Partial>, platform: NodeJS.Platform = process.platform ): string[] { @@ -23,11 +29,7 @@ export function structuredAgentConfiguredArgs( ) // An unclosed quote is the tokenizer's only failure. if (!parsed.ok) { - throw new StructuredAgentArgumentsError( - agent === 'codex' ? 'Codex' : 'Claude', - 'quote', - 'unclosedQuote' - ) + throw new StructuredAgentArgumentsError(AGENT_NAMES[agent], 'quote', 'unclosedQuote') } return parsed.tokens } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 174fcae70857..b1c9d0c7f1a4 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -241,6 +241,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis this.requireStore().getSettings(), process.platform ), + resolveAgentCommand: (agent, options) => + resolveStructuredAgentCommand(agent, this.requireStore().getSettings(), options), resolveAgentLaunchEnv: (agent) => isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) 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 dd81e303b41d..fceb9dbb606a 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 @@ -22,8 +22,16 @@ describe('execution host structured launch settings wiring', () => { it('reads saved Command and Arguments from the host settings and rereads changes', async () => { installed.mockClear() const settings: Partial = { - agentCmdOverrides: { claude: `"${process.execPath}"`, codex: `"${process.execPath}"` }, - agentDefaultArgs: { claude: '--model "model one"', codex: '-c model_reasoning_effort=high' } + agentCmdOverrides: { + claude: `"${process.execPath}"`, + codex: `"${process.execPath}"`, + grok: `"${process.execPath}"` + }, + agentDefaultArgs: { + claude: '--model "model one"', + codex: '-c model_reasoning_effort=high', + grok: '--debug -m grok-4' + } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked installer only reads the store's getSettings method. const runtime = new OrcaRuntimeService({ getSettings: () => settings } as never) @@ -34,6 +42,9 @@ describe('execution host structured launch settings wiring', () => { expect(deps?.resolveCodexCommand?.()).toBe(process.execPath) expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'model one']) expect(await deps?.resolveLaunchArgs?.('codex')).toEqual(['-c', 'model_reasoning_effort=high']) + // A Grok chat reads its own saved Arguments and Command, keyed by its agent. + expect(await deps?.resolveLaunchArgs?.('grok')).toEqual(['--debug', '-m', 'grok-4']) + expect(deps?.resolveAgentCommand?.('grok', {})).toBe(process.execPath) settings.agentDefaultArgs = { claude: '--model second', codex: '' } expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'second']) expect(await deps?.resolveLaunchArgs?.('codex')).toEqual([]) @@ -42,8 +53,11 @@ describe('execution host structured launch settings wiring', () => { expect(() => deps?.resolveClaudeCommand?.()).toThrow(notRunnable) settings.agentCmdOverrides = { codex: '/missing/codex' } expect(() => deps?.resolveCodexCommand?.()).toThrow(notRunnable) + settings.agentCmdOverrides = { grok: '/missing/grok' } + expect(() => deps?.resolveAgentCommand?.('grok', {})).toThrow(notRunnable) settings.agentCmdOverrides = {} expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) expect(deps?.resolveCodexCommand?.()).toBe(resolveCliCommand('codex')) + expect(deps?.resolveAgentCommand?.('grok', {})).toBe(resolveCliCommand('grok')) }) }) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index a0c0089c8d33..3f23a4de1d9d 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -171,6 +171,8 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveBaseEnvironment, + resolveLaunchArgs: deps.resolveLaunchArgs, + ...(deps.resolveAgentCommand ? { resolveCommand: deps.resolveAgentCommand } : {}), ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) }), diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d8a5698b0254..8171380b7c57 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -62,6 +62,7 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' /** Whether this profile holds a structured chat: a record or tab in the journal database. */ export function hasPersistedStructuredAgentSessionStore( @@ -111,6 +112,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy /** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */ resolveAgentFullAccess?: (agent: string) => boolean + /** A protocol-driven agent's saved Command, else its stock CLI on `options.pathEnv`. */ + resolveAgentCommand?: (agent: string, options: Parameters[1]) => string /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ resolveAgentLaunchEnv?: (agent: string) => Record /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ diff --git a/src/shared/agent-session-argument-problem-words.ts b/src/shared/agent-session-argument-problem-words.ts index 94c65a615d06..da3d546ab24d 100644 --- a/src/shared/agent-session-argument-problem-words.ts +++ b/src/shared/agent-session-argument-problem-words.ts @@ -16,7 +16,6 @@ const PROBLEM_COPY = { unclosedQuote: 'argumentsUnclosedQuote' } as const satisfies Record -/** Codex options a chat refuses that have an equivalent, or no need, in a chat. */ const CODEX_OPTION_HINTS = new Map([ ['--profile', 'argumentsProfileHint'], ['-p', 'argumentsProfileHint'], @@ -29,6 +28,22 @@ const CODEX_OPTION_HINTS = new Map([ ['-i', 'argumentsImageHint'], ['--image', 'argumentsImageHint'] ]) +const GROK_OPTION_HINTS = new Map([ + ['--cwd', 'argumentsWorkspaceHint'], + ['-w', 'argumentsWorkspaceHint'], + ['--worktree', 'argumentsWorkspaceHint'], + ['--worktree-ref', 'argumentsWorkspaceHint'], + ['--ref', 'argumentsWorkspaceHint'] +]) +/** Options a chat refuses that have an equivalent, or no need, in a chat. */ +const OPTION_HINTS: Record< + AgentSessionArgumentProblem['agent'], + ReadonlyMap +> = { + Codex: CODEX_OPTION_HINTS, + Claude: new Map(), + Grok: GROK_OPTION_HINTS +} function problemCopy({ option, problem }: AgentSessionArgumentProblem): AgentSessionFailureCopyId { if (problem === 'unsupportedOption' && option === '--?') { @@ -42,10 +57,7 @@ export function agentSessionArgumentProblemSentences( say: AgentSessionFailureSay ): string[] { const { agent, option, problem } = argumentProblem - const hint = - agent === 'Codex' && problem === 'unsupportedOption' - ? CODEX_OPTION_HINTS.get(option) - : undefined + const hint = problem === 'unsupportedOption' ? OPTION_HINTS[agent].get(option) : undefined return [ say(problemCopy(argumentProblem), { agent, option }), say( diff --git a/src/shared/agent-session-argument-problem.ts b/src/shared/agent-session-argument-problem.ts index 1cb10a42d6fe..e692c655e0c3 100644 --- a/src/shared/agent-session-argument-problem.ts +++ b/src/shared/agent-session-argument-problem.ts @@ -1,10 +1,11 @@ /** Orca's validated saved Arguments refusal. The option has no value or user-authored operand. */ export type AgentSessionArgumentProblem = { - agent: 'Codex' | 'Claude' + agent: 'Codex' | 'Claude' | 'Grok' option: string problem: | 'unsupportedOption' | 'missingValue' + // A single-value option given twice: Claude's, and Grok's -m. | 'multipleValues' | 'positionalPrompt' | 'unclosedQuote' @@ -32,7 +33,7 @@ export function readAgentSessionArgumentProblem( const problem = value.problem const option = value.option if ( - (agent !== 'Codex' && agent !== 'Claude') || + (agent !== 'Codex' && agent !== 'Claude' && agent !== 'Grok') || (problem !== 'unsupportedOption' && problem !== 'missingValue' && problem !== 'multipleValues' && diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index ff6f009be88c..bc32246e0218 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -766,6 +766,14 @@ describe('saved Arguments refusals', () => { { agent: 'Codex', option: 'prompt', problem: 'positionalPrompt' }, "Codex couldn't start. Saved Arguments include a prompt. Remove it from Codex's Arguments in Settings → Agents." ], + [ + { agent: 'Grok', option: '--cwd', problem: 'unsupportedOption' }, + "Grok couldn't start. Grok chats can't use --cwd from saved Arguments. Remove it from Grok's Arguments in Settings → Agents. A chat already works in its workspace folder." + ], + [ + { agent: 'Grok', option: '-m', problem: 'multipleValues' }, + "Grok couldn't start. Saved Arguments give -m more than one value. Fix it in Grok's Arguments in Settings → Agents." + ], [ { agent: 'Claude', option: 'quote', problem: 'unclosedQuote' }, "Claude couldn't start. Saved Arguments have a quote that isn't closed. Fix it in Claude's Arguments in Settings → Agents."