diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index c43f7b45b62..b05ff1f3cc0 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -488,6 +488,72 @@ describe('orchestration dispatch coordinator handle', () => { }) }) +describe('orchestration dispatch Forget + raw read CLI handlers (W-T2)', () => { + beforeEach(() => { + callMock.mockReset() + }) + + const invoke = (key: string, flags: Map) => + ORCHESTRATION_HANDLERS[key]({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it('dispatch-forget invokes dispatchForget with the task and expected failure id', async () => { + callMock.mockResolvedValue({ + dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'forgotten' } + }) + + await invoke( + 'orchestration dispatch-forget', + new Map([ + ['task', 'task_1'], + ['expected-failure-id', 'fail-1'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith('orchestration.dispatchForget', { + task: 'task_1', + expectedFailureId: 'fail-1' + }) + }) + + it('dispatch-forget omits expectedFailureId when the flag is absent', async () => { + callMock.mockResolvedValue({ + dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'forgotten' } + }) + + await invoke( + 'orchestration dispatch-forget', + new Map([['task', 'task_1']]) + ) + + expect(callMock).toHaveBeenCalledWith('orchestration.dispatchForget', { + task: 'task_1', + expectedFailureId: undefined + }) + }) + + it('dispatch-show --raw reads the un-projected status via dispatchShowRaw (never the projected read)', async () => { + callMock.mockResolvedValue({ + dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'forgotten', agent_launch_failure: null } + }) + + await invoke( + 'orchestration dispatch-show', + new Map([ + ['task', 'task_1'], + ['raw', true] + ]) + ) + + expect(callMock).toHaveBeenCalledWith('orchestration.dispatchShowRaw', { task: 'task_1' }) + expect(callMock).not.toHaveBeenCalledWith('orchestration.dispatchShow', expect.anything()) + }) +}) + describe('orchestration task-create caller handle', () => { beforeEach(() => { callMock.mockReset() diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index afe79ab8e2f..a6fcdf99ab6 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -41,6 +41,28 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record = { export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record = { 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { + if (flags.has('raw')) { + const result = await client.call<{ + dispatch: { + id: string + task_id: string + status: string + agent_launch_failure: string | null + } | null + }>('orchestration.dispatchShowRaw', { + task: getRequiredStringFlag(flags, 'task') + }) + printResult(result, json, (value) => { + if (!value.dispatch) { + return 'No dispatch context found.' + } + const failure = value.dispatch.agent_launch_failure + ? ` failure=${value.dispatch.agent_launch_failure}` + : '' + return `${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]${failure}` + }) + return + } const showPreamble = flags.has('preamble') ? true : undefined // Why: a preview must embed the same real coordinator handle as an actual dispatch. const from = showPreamble @@ -66,6 +88,21 @@ export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record { + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + }>('orchestration.dispatchForget', { + task: getRequiredStringFlag(flags, 'task'), + expectedFailureId: getOptionalStringFlag(flags, 'expected-failure-id') + }) + printResult(result, json, (value) => { + if (!value.dispatch) { + return 'No dispatch context found.' + } + return `Forgot dispatch ${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]. Task is blocked; retry with: orca orchestration task-update --id ${value.dispatch.task_id} --status ready` + }) + }, + 'orchestration coordinator-start': async () => { throw new RuntimeClientError( 'orchestration_migration_required', diff --git a/src/cli/handlers/worktree-create-agent-launch.test.ts b/src/cli/handlers/worktree-create-agent-launch.test.ts new file mode 100644 index 00000000000..7def991cc25 --- /dev/null +++ b/src/cli/handlers/worktree-create-agent-launch.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcSuccess } from '../runtime-client' +import { RuntimeClientError } from '../runtime-client' +import type { + CreatedRuntimeWorktreeCreateResult, + RuntimeWorktreeCreateResult +} from '../../shared/runtime-types' +import { buildWorktree } from '../test-fixtures' +import { + getWorktreeCreateAgentLaunch, + handleWorktreeCreatePreRejection, + printWorktreeCreateResult, + type AgentLaunchSource +} from './worktree-create-agent-launch' + +type Flags = Map + +function flags(entries: Record): Flags { + return new Map(Object.entries(entries)) +} + +function envelope( + result: RuntimeWorktreeCreateResult +): RuntimeRpcSuccess { + return { id: 'req_create', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function createdWorktree( + agentLaunchResult?: CreatedRuntimeWorktreeCreateResult['agentLaunchResult'] +): CreatedRuntimeWorktreeCreateResult { + return { + worktree: buildWorktree( + '/tmp/repo/feature', + 'feature', + 'abc', + 'repo-1' + ) as unknown as CreatedRuntimeWorktreeCreateResult['worktree'], + lineage: null, + warnings: [], + ...(agentLaunchResult ? { agentLaunchResult } : {}) + } +} + +const LAUNCHED = { + status: 'launched' as const, + receipt: { + requestedAgent: 'codex' as const, + baseAgent: 'codex' as const, + notices: [], + launchToken: 'tok-1', + catalogRevision: 1, + telemetry: { agentKind: 'codex' as const, usedCustomAgent: false } + } +} + +beforeEach(() => { + process.exitCode = 0 +}) + +afterEach(() => { + process.exitCode = 0 + vi.restoreAllMocks() +}) + +describe('getWorktreeCreateAgentLaunch', () => { + it('maps --agent to an explicit agent selection carrying the prompt', () => { + const launch = getWorktreeCreateAgentLaunch(flags({ agent: 'codex', prompt: 'do it' })) + expect(launch).toEqual({ + request: { + selection: { kind: 'agent', agent: 'codex' }, + allowEmptyPromptLaunch: true, + prompt: 'do it' + }, + source: { via: 'flag', id: 'codex' } + }) + }) + + it('maps a bare --agent to the stored default selection', () => { + const launch = getWorktreeCreateAgentLaunch(flags({ agent: true })) + expect(launch).toEqual({ + request: { selection: { kind: 'default' }, allowEmptyPromptLaunch: true }, + source: { via: 'default' } + }) + }) + + it('returns undefined when no agent is requested', () => { + expect(getWorktreeCreateAgentLaunch(flags({ name: 'feature' }))).toBeUndefined() + }) + + it('rejects --prompt without --agent before any RPC', () => { + expect(() => getWorktreeCreateAgentLaunch(flags({ prompt: 'hi' }))).toThrow( + '--prompt requires --agent' + ) + }) + + it('rejects a valueless --prompt', () => { + expect(() => getWorktreeCreateAgentLaunch(flags({ agent: 'codex', prompt: true }))).toThrow( + 'Missing value for --prompt' + ) + }) + + it('keeps an explicit empty --prompt as an empty draft', () => { + const launch = getWorktreeCreateAgentLaunch(flags({ agent: 'codex', prompt: '' })) + expect(launch?.request).toEqual({ + selection: { kind: 'agent', agent: 'codex' }, + allowEmptyPromptLaunch: true, + prompt: '' + }) + }) + + it('rejects a malformed agent id as invalid_argument', () => { + try { + getWorktreeCreateAgentLaunch(flags({ agent: 'not a real agent!!' })) + expect.unreachable('should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(RuntimeClientError) + expect((error as RuntimeClientError).code).toBe('invalid_argument') + } + }) +}) + +const FLAG_SOURCE: AgentLaunchSource = { via: 'flag', id: 'ghost' } +const DEFAULT_SOURCE: AgentLaunchSource = { via: 'default' } + +describe('handleWorktreeCreatePreRejection', () => { + it('returns the created arm unchanged when the worktree was created', () => { + const created = createdWorktree(LAUNCHED) + const result = handleWorktreeCreatePreRejection(envelope(created), FLAG_SOURCE, false) + expect(result).toBe(created) + expect(process.exitCode).toBe(0) + }) + + it('prints the stable code and human line to stderr and exits non-zero on a failed rejection', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = envelope({ + created: false, + agentLaunchResult: { status: 'failed', failure: { code: 'unknown_agent' } } + }) + + const result = handleWorktreeCreatePreRejection(response, FLAG_SOURCE, false) + + expect(result).toBeNull() + expect(errSpy.mock.calls[0][0]).toBe('unknown_agent') + expect(errSpy.mock.calls[1][0]).toContain('ghost') + expect(errSpy.mock.calls[1][0]).toContain('--agent') + expect(process.exitCode).toBe(1) + }) + + it('names the stored default agent for a default-sourced rejection', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = envelope({ + created: false, + agentLaunchResult: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'codex' } + } + }) + + handleWorktreeCreatePreRejection(response, DEFAULT_SOURCE, false) + + expect(errSpy.mock.calls[0][0]).toBe('base_agent_disabled') + expect(errSpy.mock.calls[1][0]).toContain('stored default') + expect(process.exitCode).toBe(1) + }) + + it('surfaces a request-error rejection code and exits non-zero', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = envelope({ + created: false, + agentLaunchResult: { status: 'rejected', requestError: { code: 'idempotency_conflict' } } + }) + + handleWorktreeCreatePreRejection(response, FLAG_SOURCE, false) + + expect(errSpy.mock.calls[0][0]).toBe('idempotency_conflict') + expect(process.exitCode).toBe(1) + }) + + it('prints the typed rejection envelope in JSON mode without a stderr line', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = envelope({ + created: false, + agentLaunchResult: { status: 'failed', failure: { code: 'unknown_agent' } } + }) + + handleWorktreeCreatePreRejection(response, FLAG_SOURCE, true) + + expect(logSpy.mock.calls.flat().join('\n')).toContain('unknown_agent') + expect(errSpy).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) +}) + +describe('printWorktreeCreateResult', () => { + it('prints the created worktree and leaves the exit code clean on a launched result', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const created = createdWorktree(LAUNCHED) + + printWorktreeCreateResult(envelope(created), created, FLAG_SOURCE, false) + + expect(logSpy).toHaveBeenCalled() + expect(process.exitCode).toBe(0) + }) + + it('prints the retained worktree then the stderr contract and exits non-zero on a post-create failure', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const created = createdWorktree({ + status: 'failed', + failure: { + code: 'missing_variable', + version: 1, + failureId: 'f1', + intent: 'cli', + occurredAt: 0, + variable: 'worktreePath' + } + }) + + printWorktreeCreateResult(envelope(created), created, FLAG_SOURCE, false) + + // Stable post-create output: the retained worktree prints on stdout first. + expect(logSpy.mock.calls.flat().join('\n')).toContain('/tmp/repo/feature') + expect(errSpy.mock.calls[0][0]).toBe('missing_variable') + expect(process.exitCode).toBe(1) + }) + + it('keeps the failure inside the JSON envelope without a stderr line', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const created = createdWorktree({ + status: 'failed', + failure: { + code: 'spawn_failed', + version: 1, + failureId: 'f2', + intent: 'cli', + occurredAt: 0 + } + }) + + printWorktreeCreateResult(envelope(created), created, FLAG_SOURCE, true) + + expect(logSpy.mock.calls.flat().join('\n')).toContain('spawn_failed') + expect(errSpy).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) +}) diff --git a/src/cli/handlers/worktree-create-agent-launch.ts b/src/cli/handlers/worktree-create-agent-launch.ts new file mode 100644 index 00000000000..55f4396b5d3 --- /dev/null +++ b/src/cli/handlers/worktree-create-agent-launch.ts @@ -0,0 +1,196 @@ +// CLI worktree-create agent launch: parse --agent/--prompt into the one host- +// atomic `agentLaunch` request and consume the typed result union. The CLI is +// FAIL-FAST — it never assembles a command and never falls back to a base agent. +// A pre-create rejection or a post-create failure prints a stable machine code +// plus a client-safe human line to stderr and exits non-zero; a post-create +// failure still prints the retained worktree on stdout first (stable output). + +import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request' +import type { + AgentLaunchFailure, + AgentLaunchFailureCode, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import type { + CreatedRuntimeWorktreeCreateResult, + RuntimeWorktreeCreateResult +} from '../../shared/runtime-types' +import type { WorktreeAgentLaunchRejection } from '../../shared/types' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client' +import { formatWorktreeShow, printResult } from '../format' + +type Flags = Map + +/** How the agent identity was chosen, named in the fail-fast stderr line. */ +export type AgentLaunchSource = { via: 'flag'; id: string } | { via: 'default' } + +export type WorktreeCreateAgentLaunch = { + request: AgentLaunchSpawnRequest + source: AgentLaunchSource +} + +function getPromptText(flags: Flags): string | undefined { + if (!flags.has('prompt')) { + return undefined + } + // An explicit --prompt may be empty, but a valueless --prompt is an error. + const value = flags.get('prompt') + if (typeof value !== 'string') { + throw new RuntimeClientError('invalid_argument', 'Missing value for --prompt') + } + return value +} + +function buildRequest( + selection: AgentLaunchSpawnRequest['selection'], + prompt: string | undefined +): AgentLaunchSpawnRequest { + return { + selection, + // The CLI always launches the requested agent, prompt or not. + allowEmptyPromptLaunch: true, + ...(prompt !== undefined ? { prompt } : {}) + } +} + +/** Build the host-atomic agentLaunch request from --agent/--prompt. A bare + * --agent (no value) selects the stored default; --agent names an agent. + * The host resolves identity and fails fast — the CLI sends no command/env. */ +export function getWorktreeCreateAgentLaunch(flags: Flags): WorktreeCreateAgentLaunch | undefined { + if (!flags.has('agent')) { + if (flags.has('prompt')) { + throw new RuntimeClientError('invalid_argument', '--prompt requires --agent') + } + return undefined + } + const prompt = getPromptText(flags) + const value = flags.get('agent') + if (value === true) { + return { request: buildRequest({ kind: 'default' }, prompt), source: { via: 'default' } } + } + if (typeof value === 'string' && value.length > 0) { + if (!isTuiAgent(value)) { + throw new RuntimeClientError('invalid_argument', `Unknown TUI agent "${value}"`) + } + return { + request: buildRequest({ kind: 'agent', agent: value }, prompt), + source: { via: 'flag', id: value } + } + } + throw new RuntimeClientError('invalid_argument', 'Missing value for --agent') +} + +// Client-safe reasons: never reference argv, env keys/values, paths, or labels. +const FAILURE_REASONS: Record = { + unknown_agent: 'the agent no longer exists', + no_agent_selected: 'no agent is selected — set a default or pass --agent ', + agent_definition_needs_repair: 'the agent is not fully configured (finish it in Settings)', + custom_agent_disabled: 'the agent is turned off (enable it in Settings)', + agent_configuration_changed: 'the agent configuration changed (review it in Settings)', + base_agent_disabled: 'the underlying agent is turned off (enable it in Settings)', + base_agent_unavailable: 'the underlying agent is not available on this host', + missing_variable: 'the workspace path could not be resolved for this launch', + missing_target_home: 'the home directory on the target host could not be resolved', + invalid_command_override: 'the command override is invalid (fix it in Settings)', + invalid_agent_args: 'the launch arguments are invalid (fix them in Settings)', + invalid_agent_env: 'the launch environment is invalid (fix it in Settings)', + secure_env_transport_unavailable: 'the environment cannot be sent securely to the remote host', + launch_command_too_long: 'the launch command is too long to run', + invalid_launch_snapshot: 'the saved launch details are no longer valid', + trust_preflight_failed: 'workspace trust could not be confirmed', + spawn_failed: 'the agent could not be started', + launch_state_unknown: 'the launch status is unknown', + launch_capacity_exceeded: 'too many agent launches are in progress' +} + +const REQUEST_ERROR_REASONS: Record = { + idempotency_conflict: 'this launch is already in progress', + stale_agent_launch_failure: 'this launch was already resolved', + untrusted_reference: 'the launch source could not be verified' +} + +function describeSource(source: AgentLaunchSource, requestedAgent: string | undefined): string { + if (source.via === 'flag') { + return `agent "${source.id}" requested via --agent` + } + return requestedAgent + ? `the stored default agent "${requestedAgent}"` + : 'the stored default agent' +} + +function failureHumanLine(failure: AgentLaunchFailure, source: AgentLaunchSource): string { + return `Could not launch ${describeSource(source, failure.requestedAgent)}: ${FAILURE_REASONS[failure.code]}.` +} + +function rejectionParts( + rejection: WorktreeAgentLaunchRejection, + source: AgentLaunchSource +): { code: string; human: string } { + if (rejection.status === 'failed') { + return { code: rejection.failure.code, human: failureHumanLine(rejection.failure, source) } + } + const requested = source.via === 'flag' ? source.id : 'the stored default agent' + return { + code: rejection.requestError.code, + human: `Could not launch ${ + source.via === 'flag' ? `agent "${requested}"` : requested + }: ${REQUEST_ERROR_REASONS[rejection.requestError.code]}.` + } +} + +function printAgentLaunchStderr(code: string, human: string): void { + // Plan contract: stable machine-readable code on line 1, human line on line 2. + console.error(code) + console.error(human) +} + +/** Handle a pre-create rejection (`created: false`). Prints the typed rejection + * (JSON envelope) or the stderr contract (human), sets a non-zero exit, and + * returns null. Otherwise returns the created arm for normal printing. */ +export function handleWorktreeCreatePreRejection( + response: RuntimeRpcSuccess, + source: AgentLaunchSource | undefined, + json: boolean +): CreatedRuntimeWorktreeCreateResult | null { + const result = response.result + if (result.created !== false) { + return result + } + if (json) { + printResult(response, true, () => '') + } else if (source) { + const { code, human } = rejectionParts(result.agentLaunchResult, source) + printAgentLaunchStderr(code, human) + } + process.exitCode = 1 + return null +} + +/** Print the created worktree (stable output on stdout) and, when the post-create + * launch failed, emit the fail-fast stderr contract and set a non-zero exit. The + * workspace is retained either way. */ +export function printWorktreeCreateResult( + response: RuntimeRpcSuccess, + created: CreatedRuntimeWorktreeCreateResult, + source: AgentLaunchSource | undefined, + json: boolean +): void { + const createdResponse: RuntimeRpcSuccess = { + ...response, + result: created + } + printResult(createdResponse, json, formatWorktreeShow) + if (created.agentLaunchResult?.status !== 'failed') { + return + } + // JSON already carries the failure in the printed envelope; only the human + // surface needs the stderr contract. Either way the exit is non-zero. + if (!json && source) { + printAgentLaunchStderr( + created.agentLaunchResult.failure.code, + failureHumanLine(created.agentLaunchResult.failure, source) + ) + } + process.exitCode = 1 +} diff --git a/src/cli/handlers/worktree-lineage-summary.ts b/src/cli/handlers/worktree-lineage-summary.ts index 4e3d187abe9..31395b62e76 100644 --- a/src/cli/handlers/worktree-lineage-summary.ts +++ b/src/cli/handlers/worktree-lineage-summary.ts @@ -1,4 +1,4 @@ -import type { RuntimeWorktreeCreateResult } from '../../shared/runtime-types' +import type { CreatedRuntimeWorktreeCreateResult } from '../../shared/runtime-types' function getLineageSourceLabel(source: string): string { switch (source) { @@ -19,7 +19,10 @@ function getLineageSourceLabel(source: string): string { } } -export function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void { +export function printLineageSummary( + result: CreatedRuntimeWorktreeCreateResult, + json: boolean +): void { if (json) { return } diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 484bcf9ea6d..a75319be08a 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -1,8 +1,8 @@ import type { + RuntimeWorktreeCreateResult, RuntimeWorktreeListResult, RuntimeWorktreePsResult, RuntimeWorktreeRecord, - RuntimeWorktreeCreateResult, RuntimeWorktreeRemoveResult } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' @@ -20,7 +20,6 @@ import { getRequiredWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' -import { isTuiAgent } from '../../shared/tui-agent-config' import { isWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { printLineageSummary } from './worktree-lineage-summary' import { @@ -33,6 +32,11 @@ import { resolveCreateParentSelector } from './worktree-create-parent-selector' import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link' +import { + getWorktreeCreateAgentLaunch, + handleWorktreeCreatePreRejection, + printWorktreeCreateResult +} from './worktree-create-agent-launch' type HookWarningResult = { warning?: string @@ -101,20 +105,6 @@ function getPresentStringFlag( throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`) } -function getOptionalStartupAgent(flags: Map): string | undefined { - const agent = getPresentStringFlag(flags, 'agent') - if (agent === undefined) { - if (flags.has('prompt')) { - throw new RuntimeClientError('invalid_argument', '--prompt requires --agent') - } - return undefined - } - if (!isTuiAgent(agent)) { - throw new RuntimeClientError('invalid_argument', `Unknown TUI agent "${agent}"`) - } - return agent -} - function getOptionalSetupDecision( flags: Map ): 'run' | 'skip' | 'inherit' | undefined { @@ -206,7 +196,7 @@ export const WORKTREE_HANDLERS: Record = { const explicitParent = await resolveCreateParentSelector(flags, cwd, client) const explicitParentWorktree = explicitParent.parentWorktree const explicitParentWorkspace = explicitParent.parentWorkspace - const startupAgent = getOptionalStartupAgent(flags) + const agentLaunch = getWorktreeCreateAgentLaunch(flags) const setupDecision = getOptionalSetupDecision(flags) const noParent = flags.get('no-parent') === true const envParentWorkspace = @@ -229,13 +219,13 @@ export const WORKTREE_HANDLERS: Record = { } } const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue') - const activate = flags.get('activate') === true || flags.get('run-hooks') === true - const name = getRequiredStringFlag(flags, 'name') - const result = await client.call('worktree.create', { + const activate = + flags.get('activate') === true || flags.get('run-hooks') === true || Boolean(agentLaunch) + // The host resolves the agentLaunch identity and fails fast on the `cli` + // column; the CLI consumes the created / pre-create-rejection result union. + const response = await client.call('worktree.create', { repo: await getCreateRepoSelector(flags, cwdParentWorktree, client), - name, - displayName: name, - displayNameKind: 'user', + name: getRequiredStringFlag(flags, 'name'), baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), ...linearIssueLink, @@ -255,16 +245,15 @@ export const WORKTREE_HANDLERS: Record = { // Why: marks the workspace as CLI-created so the sidebar can badge and // filter it. Sent on every `worktree create` — hand-typed or agent-run. cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {}, - ...(startupAgent - ? { - startupAgent, - startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '' - } - : {}) + ...(agentLaunch ? { agentLaunch: agentLaunch.request } : {}) }) - printHookWarning(result.result, json) - printLineageSummary(result.result, json) - printResult(result, json, formatWorktreeShow) + const created = handleWorktreeCreatePreRejection(response, agentLaunch?.source, json) + if (!created) { + return + } + printHookWarning(created, json) + printLineageSummary(created, json) + printWorktreeCreateResult(response, created, agentLaunch?.source, json) }, 'worktree set': async ({ flags, client, cwd, json }) => { assertParentWorktreeFlagsCompatible(flags) diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index aaf94bb0d64..80d095a3de9 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -86,7 +86,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', + 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent []] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', @@ -116,7 +116,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'Use --no-parent when the new worktree should be independent of the current context.', '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.', 'By default this creates the worktree and its first terminal without switching the active Orca view.', - 'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.', + 'Pass --agent to launch that agent in the first terminal, or a bare --agent to launch your default agent; --prompt sends initial work to the agent.', 'With --agent --json, read the new agent handle from result.agentTerminalHandle; older runtimes return only result.startupTerminal.handle, and may return neither for folder-based repos.', 'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.', 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 26d60935771..c775088c0c8 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -192,8 +192,19 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'dispatch-show'], summary: 'Show dispatch context for a task', usage: - 'orca orchestration dispatch-show --task [--preamble] [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'] + 'orca orchestration dispatch-show --task [--preamble] [--raw] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'raw', 'from'], + notes: ['--raw shows the un-projected status (e.g. forgotten) and its launch failure.'] + }, + { + path: ['orchestration', 'dispatch-forget'], + summary: 'Forget a dispatch stranded in an unknown launch state', + usage: + 'orca orchestration dispatch-forget --task [--expected-failure-id ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'expected-failure-id'], + notes: [ + 'The task returns to blocked; retry with: orca orchestration task-update --id --status ready.' + ] }, { path: ['orchestration', 'ask'], diff --git a/src/main/agent-launch/agent-built-in-override-mutations.ts b/src/main/agent-launch/agent-built-in-override-mutations.ts new file mode 100644 index 00000000000..277f38358b1 --- /dev/null +++ b/src/main/agent-launch/agent-built-in-override-mutations.ts @@ -0,0 +1,88 @@ +// Built-in agent override mutation: persists per-agent command/args/env overrides +// for a shipped built-in. Built-in prefixes keep multi-token wrapper +// compatibility, so only control characters and hard bounds are save-rejected. + +import type { GlobalSettings } from '../../shared/types' +import type { AgentCatalogMutationRequest } from '../../shared/agent-catalog-snapshot' +import { validateCustomAgentEnv } from '../../shared/custom-tui-agents' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import { fieldError, type AgentCatalogMutationApplication } from './agent-catalog-draft-validation' + +type UpdateBuiltInMutation = Extract< + AgentCatalogMutationRequest['mutation'], + { kind: 'update-built-in' } +> + +export function applyUpdateBuiltIn( + mutation: UpdateBuiltInMutation, + settings: GlobalSettings, + newRevision: number +): AgentCatalogMutationApplication { + if (!isBuiltInTuiAgent(mutation.agent)) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + // Built-in prefixes keep multi-token wrapper compatibility, so only + // control characters and bounds are save-rejected here; operator tokens + // fail at launch with a repairable error instead of being reinterpreted. + const override = mutation.changes.commandOverride + if (override !== null && override !== undefined) { + if (override.length > 4096) { + return { ok: false, code: 'invalid_agent_field', field: 'commandOverride', reason: 'bounds' } + } + // eslint-disable-next-line no-control-regex -- rejecting control chars is the point + if (/[\0\r\n\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(override)) { + return { + ok: false, + code: 'invalid_agent_field', + field: 'commandOverride', + reason: 'control_char' + } + } + } + if (typeof mutation.changes.args === 'string' && mutation.changes.args.length > 8192) { + return { ok: false, code: 'invalid_agent_field', field: 'args', reason: 'bounds' } + } + const envIssues = validateCustomAgentEnv(mutation.changes.env) + // Built-in env keeps the shipped permissive shape except hard safety + // bounds; reserved/prototype checks still apply to new writes. + const blocking = envIssues.find( + (issue) => + issue.reason === 'prototype_key' || + issue.reason === 'control_char' || + issue.reason === 'env_total_bounds' || + issue.reason === 'bounds' + ) + if (blocking) { + return fieldError(blocking) + } + const agent = mutation.agent + const nextCmdOverrides = { ...settings.agentCmdOverrides } + if (override === null || override === undefined || override.trim().length === 0) { + delete nextCmdOverrides[agent] + } else { + nextCmdOverrides[agent] = override + } + const nextArgs = { ...settings.agentDefaultArgs } + if (mutation.changes.args.trim().length === 0) { + delete nextArgs[agent] + } else { + nextArgs[agent] = mutation.changes.args + } + const nextEnv = { ...settings.agentDefaultEnv } + if (Object.keys(mutation.changes.env).length === 0) { + delete nextEnv[agent] + } else { + nextEnv[agent] = { ...mutation.changes.env } + } + return { + ok: true, + patch: { + agentCmdOverrides: nextCmdOverrides, + agentDefaultArgs: nextArgs, + agentDefaultEnv: nextEnv, + agentCatalogRevision: newRevision + }, + newRevision, + prunedTombstoneIds: [] + } +} diff --git a/src/main/agent-launch/agent-catalog-draft-validation.ts b/src/main/agent-launch/agent-catalog-draft-validation.ts new file mode 100644 index 00000000000..5651b9343d4 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-draft-validation.ts @@ -0,0 +1,223 @@ +// Draft validation and definition building for agent-catalog mutations: field +// checks, label-collision rules, tombstone pruning, and per-agent cache cleanup. +// Pure helpers shared by the mutation engine and repair mutations. + +import type { + BuiltInTuiAgent, + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + GlobalSettings, + TuiAgent +} from '../../shared/types' +import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot' +import { + canonicalizeCommandOverride, + isBuiltInAgentLabelKey, + normalizeAgentLabelKey, + normalizeAgentLabelText, + validateAgentArgs, + validateAgentLabel, + validateCommandOverride, + validateCustomAgentEnv, + type AgentCatalog, + type AgentFieldIssue +} from '../../shared/custom-tui-agents' +import { canonicalizeAgentArgsLineEndings } from '../../shared/agent-args-tokenizer' + +export type AgentCatalogMutationError = { + ok: false + code: + | 'catalog_revision_conflict' + | 'duplicate_agent_label' + | 'invalid_agent_field' + | 'stale_agent_repair_token' + | 'agent_catalog_local_payload_too_large' + | 'agent_catalog_payload_too_large' + field?: 'label' | 'commandOverride' | 'args' | 'env' + reason?: + | 'empty' + | 'bounds' + | 'reserved_name' + | 'prototype_key' + | 'case_collision' + | 'control_char' + | 'unterminated_quote' + | 'quoted_line_break' + | 'shell_operator' + | 'platform_ambiguous' + | 'duplicate_id' + | 'identity_mismatch' + | 'env_total_bounds' + envEntryIndex?: number +} + +export type AgentCatalogMutationApplication = + | { + ok: true + /** Applied in one store write; includes the bumped catalog revision. */ + patch: Partial + newRevision: number + mintedId?: CustomTuiAgentId + prunedTombstoneIds: CustomTuiAgentId[] + } + | AgentCatalogMutationError + +export type TombstoneReferenceCount = number | 'unknown' + +export function fieldError(issue: AgentFieldIssue): AgentCatalogMutationError { + return { + ok: false, + code: 'invalid_agent_field', + field: issue.field === 'identity' || issue.field === 'baseAgent' ? undefined : issue.field, + reason: issue.reason, + ...(issue.envEntryIndex !== undefined ? { envEntryIndex: issue.envEntryIndex } : {}) + } +} + +export function validateDraft(draft: CustomAgentDraft): AgentCatalogMutationError | null { + const labelIssue = validateAgentLabel(draft.label) + if (labelIssue) { + return fieldError(labelIssue) + } + if (draft.commandOverride !== null && draft.commandOverride !== undefined) { + const commandIssue = validateCommandOverride(draft.commandOverride) + if (commandIssue) { + return fieldError(commandIssue) + } + } + const argsIssue = validateAgentArgs(draft.args) + if (argsIssue) { + return fieldError(argsIssue) + } + const envIssues = validateCustomAgentEnv(draft.env) + if (envIssues.length > 0) { + return fieldError(envIssues[0]) + } + return null +} + +export function draftToDefinition( + id: CustomTuiAgentId, + baseAgent: BuiltInTuiAgent, + draft: CustomAgentDraft +): CustomTuiAgent { + const env: Record = Object.create(null) as Record + for (const [key, value] of Object.entries(draft.env)) { + env[key] = value + } + const commandOverride = + draft.commandOverride === null || draft.commandOverride === undefined + ? undefined + : canonicalizeCommandOverride(draft.commandOverride) + return { + id, + baseAgent, + label: normalizeAgentLabelText(draft.label), + ...(commandOverride ? { commandOverride } : {}), + args: canonicalizeAgentArgsLineEndings(draft.args), + env, + syncEnv: draft.syncEnv === true + } +} + +/** Labels reserved against the new/edited label: built-in canonical names, live + * custom labels (excluding the row being edited), and referenced tombstones. */ +export function labelCollides( + candidateKey: string, + catalog: AgentCatalog, + retainedTombstones: readonly DeletedCustomTuiAgent[], + excludeId?: CustomTuiAgentId +): boolean { + if (isBuiltInAgentLabelKey(candidateKey)) { + return true + } + for (const agent of catalog.liveCustomAgents) { + if (agent.id !== excludeId && normalizeAgentLabelKey(agent.label) === candidateKey) { + return true + } + } + for (const tombstone of retainedTombstones) { + if (normalizeAgentLabelKey(tombstone.label) === candidateKey) { + return true + } + } + return false +} + +/** Conservative unreferenced-tombstone prune: authoritative zero references + * frees the tombstone (and its label); 'unknown' retains. */ +export function pruneTombstones( + tombstones: readonly DeletedCustomTuiAgent[], + countReferences: (id: CustomTuiAgentId) => TombstoneReferenceCount +): { retained: DeletedCustomTuiAgent[]; prunedIds: CustomTuiAgentId[] } { + const retained: DeletedCustomTuiAgent[] = [] + const prunedIds: CustomTuiAgentId[] = [] + for (const tombstone of tombstones) { + const count = countReferences(tombstone.id) + if (count === 0) { + prunedIds.push(tombstone.id) + } else { + retained.push(tombstone) + } + } + return { retained, prunedIds } +} + +type AgentKeyedCacheHolder = { + selectedModelByAgent?: Partial> + selectedModelByAgentByHost?: Partial>>> + discoveredModelsByAgent?: Partial> + discoveredModelsByAgentByHost?: Partial>>> +} + +function stripAgentKeysFromHolder(holder: AgentKeyedCacheHolder, id: CustomTuiAgentId): boolean { + let changed = false + for (const flat of [holder.selectedModelByAgent, holder.discoveredModelsByAgent]) { + if (flat && id in flat) { + delete flat[id] + changed = true + } + } + for (const byHost of [holder.selectedModelByAgentByHost, holder.discoveredModelsByAgentByHost]) { + if (!byHost) { + continue + } + for (const host of Object.keys(byHost)) { + const byAgent = byHost[host] + if (byAgent && id in byAgent) { + delete byAgent[id] + changed = true + } + } + } + return changed +} + +// Ids are never reused, so per-agent model/discovery caches keyed by the deleted +// id are removed in the same settings write instead of lingering forever. +export function stripAgentKeyedModelCaches( + settings: GlobalSettings, + id: CustomTuiAgentId +): Partial { + const patch: Partial = {} + if (settings.sourceControlAi) { + const next = structuredClone(settings.sourceControlAi) + let changed = stripAgentKeysFromHolder(next, id) + for (const choice of Object.values(next.modelOverridesByOperation ?? {})) { + if (choice && stripAgentKeysFromHolder(choice, id)) { + changed = true + } + } + if (changed) { + patch.sourceControlAi = next + } + } + if (settings.commitMessageAi) { + const next = structuredClone(settings.commitMessageAi) + if (stripAgentKeysFromHolder(next, id)) { + patch.commitMessageAi = next + } + } + return patch +} diff --git a/src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts b/src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts new file mode 100644 index 00000000000..939af853bbc --- /dev/null +++ b/src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts @@ -0,0 +1,172 @@ +// End-to-end forward-rollback fixture on a DISPOSABLE profile (plan §1021-1028, +// oracle 39). The unit migration tests own field mapping; this fixture proves the +// operational contract: migrate v0→v1, exercise a v1 reference through the real +// resolver, confirm a forward-rollback build still resolves saved identities, and +// restore the pinned pre-v1 backup as the only supported downgrade. Never touches +// user data — every path is under a fresh mkdtemp dir removed in afterEach. + +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { CustomTuiAgentId, GlobalSettings } from '../../shared/types' +import { + AGENT_CATALOG_SCHEMA_VERSION, + createPinnedPreV1Backup, + migrateAgentCatalogSchema, + pinnedPreV1BackupPath +} from './agent-catalog-schema-migration' +import { resolveAgentLaunch } from './resolve-agent-launch' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf +} from './agent-launch-test-catalog' + +let dir: string +let dataFile: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-forward-rollback-')) + dataFile = join(dir, 'orca-settings.json') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** A v0 profile: no schema version, shipped legacy `defaultTuiAgent: null` (Auto), + * and one saved custom identity that must keep resolving across the migration. */ +function writeV0Profile(): { + v0Raw: string + v0Settings: Partial + customId: CustomTuiAgentId +} { + const custom = customAgent({ + id: customId('codex'), + baseAgent: 'codex', + label: 'Prod Codex', + args: '--model o3' + }) + const v0Settings: Partial = { + customTuiAgents: [custom], + deletedCustomTuiAgents: [], + defaultTuiAgent: null + } + const v0Raw = JSON.stringify(v0Settings, null, 2) + writeFileSync(dataFile, v0Raw, { mode: 0o600 }) + return { v0Raw, v0Settings, customId: custom.id } +} + +function savedIdentityResolves(settings: Partial, id: CustomTuiAgentId): boolean { + const catalog = catalogOf({ customTuiAgents: settings.customTuiAgents ?? [] }) + return resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: id } }), + catalog, + settingsOf() + ).ok +} + +describe('agent catalog forward-rollback fixture (disposable profile)', () => { + it('migrates v0→v1, pins a same-permission backup, and resolves a v1 reference', () => { + const { v0Raw, v0Settings, customId: savedId } = writeV0Profile() + const before = statSync(dataFile).mode & 0o777 + + const outcome = migrateAgentCatalogSchema({ + settings: v0Settings, + preV1RawContents: v0Raw, + createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw) + }) + + expect(outcome.didMigrate).toBe(true) + expect(outcome.backupError).toBeUndefined() + expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBe(AGENT_CATALOG_SCHEMA_VERSION) + expect(outcome.settingsPatch.defaultTuiAgent).toBe('auto') + + // The pinned backup is a byte-exact, same-permission copy of the pre-v1 file. + const backupFile = pinnedPreV1BackupPath(dataFile) + expect(existsSync(backupFile)).toBe(true) + expect(readFileSync(backupFile, 'utf8')).toBe(v0Raw) + expect(statSync(backupFile).mode & 0o777).toBe(before) + + // Exercise a v1 reference: the migrated custom identity resolves. + const migrated: Partial = { ...v0Settings, ...outcome.settingsPatch } + expect(savedIdentityResolves(migrated, savedId)).toBe(true) + }) + + it('a forward-rollback build keeps resolving already-saved identities (v1 stays a no-op)', () => { + const { v0Raw, v0Settings, customId: savedId } = writeV0Profile() + const first = migrateAgentCatalogSchema({ + settings: v0Settings, + preV1RawContents: v0Raw, + createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw) + }) + const migrated: Partial = { ...v0Settings, ...first.settingsPatch } + const v1Raw = JSON.stringify(migrated, null, 2) + writeFileSync(dataFile, v1Raw, { mode: 0o600 }) + + // A forward-rollback build re-loads the v1 file: migration is a no-op (already + // stamped), and it never disables identity resolution for saved defaults/agents. + const v1Settings = JSON.parse(readFileSync(dataFile, 'utf8')) as Partial + const reload = migrateAgentCatalogSchema({ + settings: v1Settings, + preV1RawContents: v1Raw, + createBackup: () => createPinnedPreV1Backup(dataFile, v1Raw) + }) + expect(reload.didMigrate).toBe(false) + expect(savedIdentityResolves(v1Settings, savedId)).toBe(true) + }) + + it('restores the pinned pre-v1 backup as the only supported downgrade (discards v1 metadata)', () => { + const { v0Raw, v0Settings } = writeV0Profile() + const outcome = migrateAgentCatalogSchema({ + settings: v0Settings, + preV1RawContents: v0Raw, + createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw) + }) + // Simulate the v1 write plus later v1-only metadata beyond the backup point. + const migrated: Partial = { + ...v0Settings, + ...outcome.settingsPatch, + agentReferenceRevision: 7 + } + writeFileSync(dataFile, JSON.stringify(migrated, null, 2), { mode: 0o600 }) + + // Explicit user downgrade = restore the pinned backup over the data file. + const backupRaw = readFileSync(pinnedPreV1BackupPath(dataFile), 'utf8') + writeFileSync(dataFile, backupRaw, { mode: 0o600 }) + + // Byte-identical pre-v1 state; the post-backup v1 metadata is intentionally gone. + expect(readFileSync(dataFile, 'utf8')).toBe(v0Raw) + const restored = JSON.parse(backupRaw) as Partial + expect(restored.agentCatalogSchemaVersion).toBeUndefined() + expect(restored.agentReferenceRevision).toBeUndefined() + }) + + it('a crash after backup but before the v1 write leaves a complete, restorable v0 file (never half-migrated)', () => { + const { v0Raw, v0Settings } = writeV0Profile() + // The backup is created BEFORE any v1 write, so a crash mid-migration finds the + // complete old file plus a usable backup — the oracle-39 boundary guarantee. + const backup = createPinnedPreV1Backup(dataFile, v0Raw) + expect(backup).toEqual({ ok: true, created: true }) + + // Crash: no v1 patch is written. On-disk data file is still the complete v0. + expect(readFileSync(dataFile, 'utf8')).toBe(v0Raw) + const parsed = JSON.parse(readFileSync(dataFile, 'utf8')) as Partial + expect(parsed.agentCatalogSchemaVersion).toBeUndefined() + + // And the backup independently restores a complete v0 file. + expect(readFileSync(pinnedPreV1BackupPath(dataFile), 'utf8')).toBe(v0Raw) + + // A restart re-runs the migration cleanly from the intact v0 state. + const retry = migrateAgentCatalogSchema({ + settings: v0Settings, + preV1RawContents: v0Raw, + createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw) + }) + expect(retry.didMigrate).toBe(true) + expect(retry.backupError).toBeUndefined() + }) +}) diff --git a/src/main/agent-launch/agent-catalog-lifecycle-mutations.ts b/src/main/agent-launch/agent-catalog-lifecycle-mutations.ts new file mode 100644 index 00000000000..e6587670e1d --- /dev/null +++ b/src/main/agent-launch/agent-catalog-lifecycle-mutations.ts @@ -0,0 +1,316 @@ +// Lifecycle mutations for custom agents: create, duplicate, update-custom, +// delete, set-enabled, and set-default. Each returns one atomic settings patch +// and performs no write on failure. + +import type { + BuiltInTuiAgent, + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + GlobalSettings, + TuiAgent +} from '../../shared/types' +import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot' +import { + isCustomTuiAgentId, + mintCustomTuiAgentId, + normalizeAgentLabelKey, + type AgentCatalog +} from '../../shared/custom-tui-agents' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import { + draftToDefinition, + labelCollides, + pruneTombstones, + stripAgentKeyedModelCaches, + validateDraft, + type AgentCatalogMutationApplication +} from './agent-catalog-draft-validation' +import { + isLegacyAgentPrefixPlatformAmbiguous, + tokenizeLegacyAgentPrefix +} from '../../shared/legacy-agent-prefix-tokenizer' +import type { ApplyAgentCatalogMutationArgs, MutationContext } from './agent-catalog-mutations' + +export function applyCreate( + baseAgent: BuiltInTuiAgent, + draft: CustomAgentDraft, + context: MutationContext +): AgentCatalogMutationApplication { + if (!isBuiltInTuiAgent(baseAgent)) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const draftError = validateDraft(draft) + if (draftError) { + return draftError + } + // Prune before label validation so a freed tombstone label can be reused. + const { retained, prunedIds } = pruneTombstones( + context.persistedTombstones, + context.args.countTombstoneReferences + ) + const candidateKey = normalizeAgentLabelKey(draft.label) + if (labelCollides(candidateKey, context.catalog, retained)) { + return { ok: false, code: 'duplicate_agent_label', field: 'label' } + } + const id = mintCustomTuiAgentId(baseAgent) + const definition = draftToDefinition(id, baseAgent, draft) + return { + ok: true, + patch: { + customTuiAgents: [...context.persistedLive, definition] as CustomTuiAgent[], + deletedCustomTuiAgents: retained, + agentCatalogRevision: context.newRevision + }, + newRevision: context.newRevision, + mintedId: id, + prunedTombstoneIds: prunedIds + } +} + +export function applyDuplicate( + sourceAgent: TuiAgent, + label: string, + context: MutationContext +): AgentCatalogMutationApplication { + const settings = context.args.settings + let baseAgent: BuiltInTuiAgent + let draft: CustomAgentDraft + if (isBuiltInTuiAgent(sourceAgent)) { + baseAgent = sourceAgent + const prefix = settings.agentCmdOverrides?.[sourceAgent] + let commandOverride: string | null = null + let prefixArgs = '' + if (typeof prefix === 'string' && prefix.trim().length > 0) { + // Main repeats the cross-shell equivalence gate even when the dialog was + // bypassed: an ambiguous raw prefix must not be split by guessing one + // platform's grammar. + if (isLegacyAgentPrefixPlatformAmbiguous(prefix)) { + return { + ok: false, + code: 'invalid_agent_field', + field: 'commandOverride', + reason: 'platform_ambiguous' + } + } + // Ambiguity is excluded, so every grammar agrees — posix serves. A uniform + // tokenize failure (operator/control/unterminated) surfaces for repair + // instead of being split. + const tokenized = tokenizeLegacyAgentPrefix(prefix, 'posix') + if (!tokenized.ok) { + return { + ok: false, + code: 'invalid_agent_field', + field: 'commandOverride', + reason: tokenized.reason + } + } + commandOverride = tokenized.tokens[0] ?? null + prefixArgs = tokenized.tokens.slice(1).join(' ') + } + const userArgs = settings.agentDefaultArgs?.[sourceAgent] ?? '' + const combinedArgs = [prefixArgs, userArgs].filter((part) => part.length > 0).join(' ') + draft = { + label, + commandOverride, + args: combinedArgs, + env: { ...settings.agentDefaultEnv?.[sourceAgent] }, + syncEnv: false + } + } else { + // Duplicate requires a live source at the expected revision — never a + // tombstone (deleted config is unrecoverable by design). + const source = context.catalog.liveById.get(sourceAgent as CustomTuiAgentId) + if (!source) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + baseAgent = source.baseAgent + draft = { + label, + commandOverride: source.commandOverride ?? null, + args: source.args, + env: { ...source.env }, + // Duplicate always resets paired-launch env opt-in to off. + syncEnv: false + } + } + // A duplicate of a disabled live custom stays enabled: the new id is not in + // disabledTuiAgents and the user re-disables explicitly if wanted. + return applyCreate(baseAgent, draft, context) +} + +export function applyUpdateCustom( + id: CustomTuiAgentId, + changes: CustomAgentDraft, + context: MutationContext +): AgentCatalogMutationApplication { + const { args, catalog, persistedLive, persistedTombstones, newRevision } = context + const existing = catalog.liveById.get(id) + const repairRow = catalog.repairRequiredById.get(id) + if (!existing && !repairRow) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const baseAgent = existing?.baseAgent ?? repairRow?.baseAgent + if (!baseAgent) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const draftError = validateDraft(changes) + if (draftError) { + return draftError + } + const candidateKey = normalizeAgentLabelKey(changes.label) + const retained = persistedTombstones.filter( + (tombstone) => args.countTombstoneReferences(tombstone.id) !== 0 + ) + if (labelCollides(candidateKey, catalog, retained, id)) { + return { ok: false, code: 'duplicate_agent_label', field: 'label' } + } + const nextDefinition = draftToDefinition(id, baseAgent, changes) + // Updates preserve the row's physical index (creation-order authority). + const nextLive = persistedLive.map((row) => { + const rowId = (row as { id?: unknown })?.id + return rowId === id ? nextDefinition : row + }) + return { + ok: true, + patch: { customTuiAgents: nextLive as CustomTuiAgent[], agentCatalogRevision: newRevision }, + newRevision, + prunedTombstoneIds: [] + } +} + +export function applyDelete( + id: CustomTuiAgentId, + onDefault: 'keep' | 'base' | 'auto' | 'clear', + context: MutationContext +): AgentCatalogMutationApplication { + const { catalog, args } = context + const existing = catalog.liveById.get(id) ?? null + const repairRow = catalog.repairRequiredById.get(id) ?? null + if (!existing && !repairRow) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const baseAgent = existing?.baseAgent ?? repairRow?.baseAgent + const label = existing?.label ?? repairRow?.label ?? '' + if (!baseAgent) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + // Tombstone before removing the live entry so a crash between the two can + // only over-retain, never resurrect or orphan references. + const tombstone: DeletedCustomTuiAgent = { + id, + baseAgent, + label, + deletedAt: Date.now() + } + const nextTombstones = [ + ...context.persistedTombstones.filter((entry) => entry.id !== id), + tombstone + ] + const nextLive = context.persistedLive.filter((row) => (row as { id?: unknown })?.id !== id) + const nextDisabled = (args.settings.disabledTuiAgents ?? []).filter((entry) => entry !== id) + + const patch: Partial = { + customTuiAgents: nextLive as CustomTuiAgent[], + deletedCustomTuiAgents: nextTombstones, + disabledTuiAgents: nextDisabled, + agentCatalogRevision: context.newRevision, + ...stripAgentKeyedModelCaches(args.settings, id) + } + + if (args.settings.defaultTuiAgent === id) { + switch (onDefault) { + case 'keep': + break + case 'base': + // Rebinding to the base requires the base to be currently enabled; + // otherwise fall through to clear so the default never lands disabled. + patch.defaultTuiAgent = catalog.disabledAgents.has(baseAgent) ? null : baseAgent + break + case 'auto': + patch.defaultTuiAgent = 'auto' + break + case 'clear': + patch.defaultTuiAgent = null + break + } + } + + return { + ok: true, + patch, + newRevision: context.newRevision, + prunedTombstoneIds: [] + } +} + +export function applySetEnabled( + agent: TuiAgent, + enabled: boolean, + context: { args: ApplyAgentCatalogMutationArgs; catalog: AgentCatalog; newRevision: number } +): AgentCatalogMutationApplication { + const { catalog, args } = context + const known = + isBuiltInTuiAgent(agent) || + (isCustomTuiAgentId(agent) && + (catalog.liveById.has(agent) || catalog.repairRequiredById.has(agent))) + if (!known) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const current = args.settings.disabledTuiAgents ?? [] + const without = current.filter((entry) => entry !== agent) + const nextDisabled = enabled ? without : [...without, agent] + const patch: Partial = { + disabledTuiAgents: nextDisabled, + agentCatalogRevision: context.newRevision + } + if (!enabled && isBuiltInTuiAgent(agent)) { + // Disabling a base repairs a base/derivative default to null in the same + // write: no fallback is launchable under a disabled base. Auto stays Auto. + const currentDefault = args.settings.defaultTuiAgent + if (currentDefault === agent) { + patch.defaultTuiAgent = null + } else if (isCustomTuiAgentId(currentDefault ?? undefined)) { + const identity = + catalog.liveById.get(currentDefault as CustomTuiAgentId) ?? + catalog.tombstonesById.get(currentDefault as CustomTuiAgentId) ?? + catalog.repairRequiredById.get(currentDefault as CustomTuiAgentId) + if (identity && 'baseAgent' in identity && identity.baseAgent === agent) { + patch.defaultTuiAgent = null + } + } + } + return { ok: true, patch, newRevision: context.newRevision, prunedTombstoneIds: [] } +} + +export function applySetDefault( + target: TuiAgent | 'auto' | 'blank', + catalog: AgentCatalog, + newRevision: number +): AgentCatalogMutationApplication { + if (target !== 'auto' && target !== 'blank') { + const identity = isBuiltInTuiAgent(target) + ? target + : catalog.liveById.get(target) + ? target + : null + if (!identity) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const base = isBuiltInTuiAgent(target) + ? target + : catalog.liveById.get(target as CustomTuiAgentId)?.baseAgent + if ( + catalog.disabledAgents.has(target) || + (base !== undefined && catalog.disabledAgents.has(base)) + ) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + } + return { + ok: true, + patch: { defaultTuiAgent: target, agentCatalogRevision: newRevision }, + newRevision, + prunedTombstoneIds: [] + } +} diff --git a/src/main/agent-launch/agent-catalog-mutations.test.ts b/src/main/agent-launch/agent-catalog-mutations.test.ts new file mode 100644 index 00000000000..0e1f089026a --- /dev/null +++ b/src/main/agent-launch/agent-catalog-mutations.test.ts @@ -0,0 +1,685 @@ +import { describe, expect, it } from 'vitest' +import type { + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + GlobalSettings +} from '../../shared/types' +import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot' +import { + AgentCatalogRepairTokenRegistry, + applyAgentCatalogMutation, + type ApplyAgentCatalogMutationArgs +} from './agent-catalog-mutations' + +const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' +const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' + +function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +function liveAgent(overrides: Partial = {}): CustomTuiAgent { + return { + id: customId('codex'), + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +function draft(overrides: Partial = {}): CustomAgentDraft { + return { + label: 'New Agent', + commandOverride: null, + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +function settingsWith(overrides: Partial = {}): GlobalSettings { + return { + defaultTuiAgent: 'auto', + disabledTuiAgents: [], + customTuiAgents: [], + deletedCustomTuiAgents: [], + agentCatalogRevision: 5, + agentCmdOverrides: {}, + ...overrides + } as GlobalSettings +} + +function apply( + overrides: Partial & { + mutation: ApplyAgentCatalogMutationArgs['request']['mutation'] + expectedRevision?: number + } +) { + const { mutation, expectedRevision, ...rest } = overrides + return applyAgentCatalogMutation({ + settings: settingsWith(), + currentRevision: 5, + repairTokens: new AgentCatalogRepairTokenRegistry(), + countTombstoneReferences: () => 0, + ...rest, + request: { expectedRevision: expectedRevision ?? 5, mutation } + }) +} + +describe('revision gating', () => { + it('rejects a stale expectedRevision without writing', () => { + const result = apply({ + mutation: { kind: 'create', baseAgent: 'codex', draft: draft() }, + expectedRevision: 4 + }) + expect(result).toEqual({ ok: false, code: 'catalog_revision_conflict' }) + }) +}) + +describe('create', () => { + it('mints a canonical id and appends in creation order', () => { + const existing = liveAgent({ label: 'Existing' }) + const result = apply({ + settings: settingsWith({ customTuiAgents: [existing] }), + mutation: { kind: 'create', baseAgent: 'claude', draft: draft() } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.newRevision).toBe(6) + const live = result.patch.customTuiAgents ?? [] + expect(live).toHaveLength(2) + expect(live[0].label).toBe('Existing') + expect(live[1].id).toBe(result.mintedId) + expect(live[1].baseAgent).toBe('claude') + expect(result.patch.agentCatalogRevision).toBe(6) + }) + + it('rejects invalid drafts with field/reason metadata', () => { + const cases: { + draft: CustomAgentDraft + field: string + reason: string + envEntryIndex?: number + }[] = [ + { draft: draft({ label: '' }), field: 'label', reason: 'empty' }, + { draft: draft({ label: 'x'.repeat(81) }), field: 'label', reason: 'bounds' }, + { + draft: draft({ commandOverride: 'codex && evil' }), + field: 'commandOverride', + reason: 'shell_operator' + }, + { + draft: draft({ commandOverride: '"unclosed' }), + field: 'commandOverride', + reason: 'unterminated_quote' + }, + { draft: draft({ args: '"a\nb"' }), field: 'args', reason: 'quoted_line_break' }, + { draft: draft({ args: '"open' }), field: 'args', reason: 'unterminated_quote' }, + { draft: draft({ args: 'x'.repeat(8193) }), field: 'args', reason: 'bounds' }, + { + draft: draft({ env: { ORCA_EVIL: 'x' } }), + field: 'env', + reason: 'reserved_name', + envEntryIndex: 0 + }, + { + draft: draft({ env: JSON.parse('{"__proto__": "x"}') as Record }), + field: 'env', + reason: 'prototype_key', + envEntryIndex: 0 + }, + { + draft: draft({ env: { Path: 'a', PATH: 'b' } }), + field: 'env', + reason: 'case_collision', + envEntryIndex: 1 + } + ] + for (const testCase of cases) { + const result = apply({ + mutation: { kind: 'create', baseAgent: 'codex', draft: testCase.draft } + }) + expect(result.ok).toBe(false) + if (result.ok) { + continue + } + expect(result.code).toBe('invalid_agent_field') + expect(result.field).toBe(testCase.field) + expect(result.reason).toBe(testCase.reason) + if (testCase.envEntryIndex !== undefined) { + expect(result.envEntryIndex).toBe(testCase.envEntryIndex) + } + } + }) + + it('rejects the 16 KiB aggregate env bound', () => { + const env: Record = {} + for (let i = 0; i < 5; i += 1) { + env[`K${i}`] = 'v'.repeat(4000) + } + const result = apply({ + mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ env }) } + }) + expect(result).toMatchObject({ + ok: false, + code: 'invalid_agent_field', + field: 'env', + reason: 'env_total_bounds' + }) + }) + + it('accepts multiline args (the editor is real, not cosmetic)', () => { + const result = apply({ + mutation: { + kind: 'create', + baseAgent: 'codex', + draft: draft({ args: '--model x\n--safe "two words"' }) + } + }) + expect(result.ok).toBe(true) + }) + + it('normalizes CRLF to LF on save', () => { + const result = apply({ + mutation: { + kind: 'create', + baseAgent: 'codex', + draft: draft({ args: '--a\r\n--b' }) + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.customTuiAgents?.[0].args).toBe('--a\n--b') + }) + + it('rejects label collisions with built-in canonical names, live labels, and referenced tombstones', () => { + const live = liveAgent({ label: 'Mine' }) + const tombstone: DeletedCustomTuiAgent = { + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Kept Name', + deletedAt: 1 + } + const settings = settingsWith({ + customTuiAgents: [live], + deletedCustomTuiAgents: [tombstone] + }) + for (const label of ['Codex', ' codex ', 'MINE', 'kept name']) { + const result = apply({ + settings, + countTombstoneReferences: () => 1, + mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ label }) } + }) + expect(result).toMatchObject({ ok: false, code: 'duplicate_agent_label' }) + } + }) + + it('prunes unreferenced tombstones before label validation, freeing the name', () => { + const tombstone: DeletedCustomTuiAgent = { + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Freed Name', + deletedAt: 1 + } + const result = apply({ + settings: settingsWith({ deletedCustomTuiAgents: [tombstone] }), + countTombstoneReferences: () => 0, + mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ label: 'Freed Name' }) } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.prunedTombstoneIds).toEqual([tombstone.id]) + expect(result.patch.deletedCustomTuiAgents).toEqual([]) + }) + + it('retains tombstones when a reference scan is unknown', () => { + const tombstone: DeletedCustomTuiAgent = { + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Retained Name', + deletedAt: 1 + } + const result = apply({ + settings: settingsWith({ deletedCustomTuiAgents: [tombstone] }), + countTombstoneReferences: () => 'unknown', + mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ label: 'Retained Name' }) } + }) + expect(result).toMatchObject({ ok: false, code: 'duplicate_agent_label' }) + }) +}) + +describe('duplicate', () => { + it('duplicates a disabled live custom into an enabled copy with syncEnv false', () => { + const source = liveAgent({ + label: 'Source', + commandOverride: '/opt/codex', + args: '--model x', + env: { FOO: 'bar' }, + syncEnv: true + }) + const result = apply({ + settings: settingsWith({ + customTuiAgents: [source], + disabledTuiAgents: [source.id] + }), + mutation: { kind: 'duplicate', sourceAgent: source.id, label: 'Copy' } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const copy = result.patch.customTuiAgents?.find((agent) => agent.id === result.mintedId) + expect(copy).toMatchObject({ + label: 'Copy', + baseAgent: 'codex', + commandOverride: '/opt/codex', + args: '--model x', + env: { FOO: 'bar' }, + syncEnv: false + }) + // Enabled copy: the disabled list is untouched (the new id is not added). + expect(result.patch.disabledTuiAgents).toBeUndefined() + }) + + it('never duplicates from a tombstone', () => { + const tombstone: DeletedCustomTuiAgent = { + id: customId('codex'), + baseAgent: 'codex', + label: 'Gone', + deletedAt: 1 + } + const result = apply({ + settings: settingsWith({ deletedCustomTuiAgents: [tombstone] }), + mutation: { kind: 'duplicate', sourceAgent: tombstone.id, label: 'Copy' } + }) + expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field' }) + }) + + it('splits an unambiguous multi-token built-in prefix into executable + prepended args', () => { + const result = apply({ + settings: settingsWith({ + agentCmdOverrides: { codex: '/opt/wrap codex-real --fast' }, + agentDefaultArgs: { codex: '--user-arg' } + }), + mutation: { kind: 'duplicate', sourceAgent: 'codex', label: 'Wrapped' } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const copy = result.patch.customTuiAgents?.[0] + expect(copy?.commandOverride).toBe('/opt/wrap') + expect(copy?.args).toBe('codex-real --fast --user-arg') + }) + + it('rejects a platform-ambiguous built-in prefix instead of guessing a grammar', () => { + const result = apply({ + settings: settingsWith({ + agentCmdOverrides: { codex: 'C:\\tools\\wrap.exe codex' } + }), + mutation: { kind: 'duplicate', sourceAgent: 'codex', label: 'Wrapped' } + }) + expect(result).toMatchObject({ + ok: false, + code: 'invalid_agent_field', + field: 'commandOverride', + reason: 'platform_ambiguous' + }) + }) +}) + +describe('update-custom', () => { + it('updates in place, preserving physical index', () => { + const first = liveAgent({ id: customId('codex', UUID_A), label: 'First' }) + const second = liveAgent({ + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Second' + }) + const result = apply({ + settings: settingsWith({ customTuiAgents: [first, second] }), + mutation: { + kind: 'update-custom', + id: first.id, + changes: { + label: 'First Renamed', + commandOverride: null, + args: '--new', + env: { A: '1' }, + syncEnv: true + } + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const live = result.patch.customTuiAgents ?? [] + expect(live[0]).toMatchObject({ id: first.id, label: 'First Renamed', syncEnv: true }) + expect(live[1]).toMatchObject({ id: second.id, label: 'Second' }) + }) + + it('repairs a valid-unique-id repair-required row through update-custom', () => { + const broken = { ...liveAgent(), label: '' } + const result = apply({ + settings: settingsWith({ customTuiAgents: [broken] }), + mutation: { + kind: 'update-custom', + id: broken.id, + changes: { label: 'Fixed', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.customTuiAgents?.[0]).toMatchObject({ id: broken.id, label: 'Fixed' }) + }) + + it('rejects updates for unknown ids', () => { + const result = apply({ + mutation: { + kind: 'update-custom', + id: customId('codex', UUID_B), + changes: { label: 'X', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field' }) + }) + + it('allows keeping its own label without a false collision', () => { + const live = liveAgent({ label: 'Keep Me' }) + const result = apply({ + settings: settingsWith({ customTuiAgents: [live] }), + mutation: { + kind: 'update-custom', + id: live.id, + changes: { label: 'keep me', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(result.ok).toBe(true) + }) +}) + +describe('delete-custom', () => { + const live = liveAgent({ label: 'Doomed', args: '--secret', env: { KEY: 'value' } }) + + it('tombstones id/base/label only, removes the live row and disabled entry, and strips model caches', () => { + const result = apply({ + settings: settingsWith({ + customTuiAgents: [live], + disabledTuiAgents: [live.id, 'gemini'], + sourceControlAi: { + enabled: true, + agentId: null, + selectedModelByAgent: { [live.id]: 'model-x', codex: 'model-y' }, + selectedThinkingByModel: {}, + customAgentCommand: '', + instructionsByOperation: {} + } as GlobalSettings['sourceControlAi'], + commitMessageAi: { + enabled: true, + agentId: null, + selectedModelByAgent: { [live.id]: 'model-z' }, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } as GlobalSettings['commitMessageAi'] + }), + mutation: { kind: 'delete-custom', id: live.id } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.customTuiAgents).toEqual([]) + const tombstone = result.patch.deletedCustomTuiAgents?.[0] + expect(tombstone).toMatchObject({ id: live.id, baseAgent: 'codex', label: 'Doomed' }) + // Tombstones never carry recoverable config. + expect(tombstone && 'args' in tombstone).toBe(false) + expect(tombstone && 'env' in tombstone).toBe(false) + expect(result.patch.disabledTuiAgents).toEqual(['gemini']) + expect(result.patch.sourceControlAi?.selectedModelByAgent).toEqual({ codex: 'model-y' }) + expect(result.patch.commitMessageAi?.selectedModelByAgent).toEqual({}) + }) + + it('applies onDefault only when the deleted id is the current default', () => { + const notDefault = apply({ + settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: 'codex' }), + mutation: { kind: 'delete-custom', id: live.id, onDefault: 'clear' } + }) + expect(notDefault.ok).toBe(true) + if (!notDefault.ok) { + return + } + expect('defaultTuiAgent' in notDefault.patch).toBe(false) + + const keep = apply({ + settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }), + mutation: { kind: 'delete-custom', id: live.id, onDefault: 'keep' } + }) + expect(keep.ok).toBe(true) + if (!keep.ok) { + return + } + expect('defaultTuiAgent' in keep.patch).toBe(false) + + for (const [onDefault, expected] of [ + ['base', 'codex'], + ['auto', 'auto'], + ['clear', null] + ] as const) { + const result = apply({ + settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }), + mutation: { kind: 'delete-custom', id: live.id, onDefault } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + continue + } + expect(result.patch.defaultTuiAgent).toBe(expected) + } + }) + + it('treats onDefault base as clear when the base is disabled', () => { + const result = apply({ + settings: settingsWith({ + customTuiAgents: [live], + defaultTuiAgent: live.id, + disabledTuiAgents: ['codex'] + }), + mutation: { kind: 'delete-custom', id: live.id, onDefault: 'base' } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.defaultTuiAgent).toBeNull() + }) +}) + +describe('set-enabled', () => { + it('disables and re-enables known identities in one write each', () => { + const live = liveAgent() + const disable = apply({ + settings: settingsWith({ customTuiAgents: [live] }), + mutation: { kind: 'set-enabled', agent: live.id, enabled: false } + }) + expect(disable.ok).toBe(true) + if (!disable.ok) { + return + } + expect(disable.patch.disabledTuiAgents).toEqual([live.id]) + + const enable = apply({ + settings: settingsWith({ customTuiAgents: [live], disabledTuiAgents: [live.id] }), + mutation: { kind: 'set-enabled', agent: live.id, enabled: true } + }) + expect(enable.ok).toBe(true) + if (!enable.ok) { + return + } + expect(enable.patch.disabledTuiAgents).toEqual([]) + }) + + it('keeps a disabled custom default as the stored reference', () => { + const live = liveAgent() + const result = apply({ + settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }), + mutation: { kind: 'set-enabled', agent: live.id, enabled: false } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect('defaultTuiAgent' in result.patch).toBe(false) + }) + + it('disabling a base repairs a base or derivative default to null in the same write', () => { + const live = liveAgent() + const derivative = apply({ + settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }), + mutation: { kind: 'set-enabled', agent: 'codex', enabled: false } + }) + expect(derivative.ok).toBe(true) + if (!derivative.ok) { + return + } + expect(derivative.patch.defaultTuiAgent).toBeNull() + + const builtIn = apply({ + settings: settingsWith({ defaultTuiAgent: 'codex' }), + mutation: { kind: 'set-enabled', agent: 'codex', enabled: false } + }) + expect(builtIn.ok).toBe(true) + if (!builtIn.ok) { + return + } + expect(builtIn.patch.defaultTuiAgent).toBeNull() + + // Auto remains Auto and simply skips the disabled base. + const auto = apply({ + settings: settingsWith({ defaultTuiAgent: 'auto' }), + mutation: { kind: 'set-enabled', agent: 'codex', enabled: false } + }) + expect(auto.ok).toBe(true) + if (!auto.ok) { + return + } + expect('defaultTuiAgent' in auto.patch).toBe(false) + }) + + it('rejects unknown identities', () => { + const result = apply({ + mutation: { kind: 'set-enabled', agent: customId('codex', UUID_B), enabled: false } + }) + expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field' }) + }) +}) + +describe('set-default', () => { + it('accepts auto, blank, and enabled live identities; the public type cannot carry null', () => { + const live = liveAgent() + for (const target of ['auto', 'blank', 'codex', live.id] as const) { + const result = apply({ + settings: settingsWith({ customTuiAgents: [live] }), + mutation: { kind: 'set-default', agent: target } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + continue + } + expect(result.patch.defaultTuiAgent).toBe(target) + } + }) + + it('rejects disabled, tombstoned, unknown, and repair-required identities', () => { + const live = liveAgent() + const broken = { + ...liveAgent({ id: customId('claude', UUID_B), baseAgent: 'claude' }), + label: '' + } + const settings = settingsWith({ + customTuiAgents: [live, broken], + disabledTuiAgents: [live.id], + deletedCustomTuiAgents: [] + }) + for (const target of [live.id, broken.id, customId('gemini', UUID_B), 'gemini'] as const) { + const useSettings = + target === 'gemini' ? settingsWith({ disabledTuiAgents: ['gemini'] }) : settings + const result = apply({ + settings: useSettings, + mutation: { kind: 'set-default', agent: target } + }) + expect(result.ok).toBe(false) + } + }) +}) + +describe('update-built-in', () => { + it('writes the three override slots and clears empty ones', () => { + const result = apply({ + settings: settingsWith({ + agentCmdOverrides: { codex: '/old' }, + agentDefaultArgs: { codex: '--old' }, + agentDefaultEnv: { codex: { OLD: '1' } } + }), + mutation: { + kind: 'update-built-in', + agent: 'codex', + changes: { commandOverride: '/new/codex', args: '', env: { NEW: '2' } } + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.agentCmdOverrides).toEqual({ codex: '/new/codex' }) + expect(result.patch.agentDefaultArgs).toEqual({}) + expect(result.patch.agentDefaultEnv).toEqual({ codex: { NEW: '2' } }) + }) + + it('rejects control characters and prototype keys while keeping multi-token compatibility', () => { + const multiToken = apply({ + mutation: { + kind: 'update-built-in', + agent: 'codex', + changes: { commandOverride: '/opt/wrap codex --flag', args: '', env: {} } + } + }) + expect(multiToken.ok).toBe(true) + + const controlChar = apply({ + mutation: { + kind: 'update-built-in', + agent: 'codex', + changes: { commandOverride: 'a\nb', args: '', env: {} } + } + }) + expect(controlChar).toMatchObject({ ok: false, reason: 'control_char' }) + + const protoKey = apply({ + mutation: { + kind: 'update-built-in', + agent: 'codex', + changes: { + commandOverride: null, + args: '', + env: JSON.parse('{"__proto__": "x"}') as Record + } + } + }) + expect(protoKey).toMatchObject({ ok: false, reason: 'prototype_key' }) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-mutations.ts b/src/main/agent-launch/agent-catalog-mutations.ts new file mode 100644 index 00000000000..8ef456999ae --- /dev/null +++ b/src/main/agent-launch/agent-catalog-mutations.ts @@ -0,0 +1,133 @@ +// Atomic agent-catalog mutation engine. Every mutation validates against the +// exact expected revision, produces one settings patch applied in one store +// write, and increments the catalog revision exactly once. Failures perform no +// write. Main owns id minting and dependent-field repair; corrupt rows are +// addressed only by opaque revision-scoped repair tokens. Draft validation, +// lifecycle, repair, and built-in override live in the re-exported siblings. + +import type { + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + GlobalSettings +} from '../../shared/types' +import type { AgentCatalogMutationRequest } from '../../shared/agent-catalog-snapshot' +import { normalizeAgentCatalog, type AgentCatalog } from '../../shared/custom-tui-agents' +import type { + AgentCatalogMutationApplication, + TombstoneReferenceCount +} from './agent-catalog-draft-validation' +import { + applyCreate, + applyDelete, + applyDuplicate, + applySetDefault, + applySetEnabled, + applyUpdateCustom +} from './agent-catalog-lifecycle-mutations' +import { applyRepairCorrupt, applyResolveDuplicateId } from './agent-catalog-repair-mutations' +import type { AgentCatalogRepairTokenRegistry } from './agent-catalog-repair-mutations' +import { applyUpdateBuiltIn } from './agent-built-in-override-mutations' + +export { AgentCatalogRepairTokenRegistry } from './agent-catalog-repair-mutations' +export type { + AgentCatalogMutationError, + AgentCatalogMutationApplication, + TombstoneReferenceCount +} from './agent-catalog-draft-validation' + +export type ApplyAgentCatalogMutationArgs = { + settings: GlobalSettings + request: AgentCatalogMutationRequest + currentRevision: number + repairTokens: AgentCatalogRepairTokenRegistry + /** Authoritative reference count per tombstone id; 'unknown' means an owner + * store could not be checked and the tombstone must be retained. */ + countTombstoneReferences: (id: CustomTuiAgentId) => TombstoneReferenceCount +} + +export type MutationContext = { + args: ApplyAgentCatalogMutationArgs + catalog: AgentCatalog + persistedLive: readonly unknown[] + persistedTombstones: readonly DeletedCustomTuiAgent[] + newRevision: number +} + +function definitionsEqualById( + agents: readonly CustomTuiAgent[] +): Map { + const map = new Map() + for (const agent of agents) { + map.set(agent.id, agent) + } + return map +} + +export function applyAgentCatalogMutation( + args: ApplyAgentCatalogMutationArgs +): AgentCatalogMutationApplication { + const { settings, request, currentRevision, repairTokens } = args + if (request.expectedRevision !== currentRevision) { + return { ok: false, code: 'catalog_revision_conflict' } + } + + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: settings.customTuiAgents, + deletedCustomTuiAgents: settings.deletedCustomTuiAgents, + disabledTuiAgents: settings.disabledTuiAgents, + defaultTuiAgent: settings.defaultTuiAgent + }) + const persistedLive = Array.isArray(settings.customTuiAgents) ? settings.customTuiAgents : [] + const persistedTombstones = Array.isArray(settings.deletedCustomTuiAgents) + ? settings.deletedCustomTuiAgents + : [] + const newRevision = currentRevision + 1 + const mutation = request.mutation + const context: MutationContext = { + args, + catalog, + persistedLive, + persistedTombstones, + newRevision + } + + switch (mutation.kind) { + case 'create': + return applyCreate(mutation.baseAgent, mutation.draft, context) + case 'duplicate': + return applyDuplicate(mutation.sourceAgent, mutation.label, context) + case 'update-custom': + return applyUpdateCustom(mutation.id, mutation.changes, context) + case 'delete-custom': + return applyDelete(mutation.id, mutation.onDefault ?? 'keep', context) + case 'set-enabled': + return applySetEnabled(mutation.agent, mutation.enabled, { args, catalog, newRevision }) + case 'set-default': + return applySetDefault(mutation.agent, catalog, newRevision) + case 'repair-corrupt': + return applyRepairCorrupt(mutation.repairToken, mutation.action, { + ...context, + repairTokens + }) + case 'resolve-duplicate-id': + return applyResolveDuplicateId(mutation.duplicateId, mutation.rows, { + ...context, + repairTokens + }) + case 'update-built-in': + return applyUpdateBuiltIn(mutation, settings, newRevision) + } +} + +export function liveDefinitionsById( + settings: GlobalSettings +): Map { + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: settings.customTuiAgents, + deletedCustomTuiAgents: settings.deletedCustomTuiAgents, + disabledTuiAgents: settings.disabledTuiAgents, + defaultTuiAgent: settings.defaultTuiAgent + }) + return definitionsEqualById(catalog.liveCustomAgents) +} diff --git a/src/main/agent-launch/agent-catalog-owner-scanners-orchestration.test.ts b/src/main/agent-launch/agent-catalog-owner-scanners-orchestration.test.ts new file mode 100644 index 00000000000..2218683dfaa --- /dev/null +++ b/src/main/agent-launch/agent-catalog-owner-scanners-orchestration.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { AgentTombstoneReferenceIndex } from './agent-tombstone-reference-index' +import { registerOrchestrationOwnerScanner } from './agent-catalog-owner-scanners' +import type { CustomTuiAgentId } from '../../shared/types' + +const deadId = 'custom-agent:codex:fedcba98-7654-4321-8fed-cba987654321' as CustomTuiAgentId + +describe('orchestration owner scanner', () => { + it('retains a tombstone while a dispatch references the id and prunes after it clears', () => { + const index = new AgentTombstoneReferenceIndex() + let referenced: string[] = [deadId] + registerOrchestrationOwnerScanner(index, () => referenced) + + expect(index.countReferences(deadId)).toBe(1) + expect(index.summarizeReferences(deadId)).toContainEqual({ owner: 'orchestration', count: 1 }) + + referenced = [] + expect(index.countReferences(deadId)).toBe(0) + }) + + it('retains conservatively (unknown) when the dispatch store cannot be read', () => { + const index = new AgentTombstoneReferenceIndex() + registerOrchestrationOwnerScanner(index, () => { + throw new Error('orchestration db unavailable') + }) + expect(index.countReferences(deadId)).toBe('unknown') + }) + + it('is idempotent so a shared index never double-counts a dispatch reference', () => { + const index = new AgentTombstoneReferenceIndex() + const accessor = (): string[] => [deadId] + registerOrchestrationOwnerScanner(index, accessor) + registerOrchestrationOwnerScanner(index, accessor) + expect(index.countReferences(deadId)).toBe(1) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-owner-scanners.ts b/src/main/agent-launch/agent-catalog-owner-scanners.ts new file mode 100644 index 00000000000..4d85533def2 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-owner-scanners.ts @@ -0,0 +1,192 @@ +// Built-in reference owner scanners: each enumerates the raw agent ids the +// settings/repo/automation/session records currently point at. The index applies +// the counting policy (custom-id tombstone GC, or base-disable impact matching); +// a scan that throws returns { ok: false } so the tombstone is conservatively +// retained. + +import type { Store } from '../persistence' +import type { GlobalSettings, TerminalQuickCommand } from '../../shared/types' +import type { AgentTombstoneReferenceIndex } from './agent-tombstone-reference-index' +import { getHostAgentSessionRecordStore } from './agent-session-record-store-host' +import { getHostBackgroundAgentLaunchStore } from './background-agent-launch-store-host' + +/** Register the desktop's built-in reference owners against the shared index. + * Later units add their own owner scanners through the same index. */ +export function registerBuiltInOwnerScanners( + index: AgentTombstoneReferenceIndex, + store: Store +): void { + const settings = (): GlobalSettings => store.getSettings() + index.register({ + owner: 'default', + scan: () => { + try { + return { ok: true, referencedIds: [settings().defaultTuiAgent] } + } catch { + return { ok: false } + } + } + }) + index.register({ + owner: 'quick-command', + scan: () => { + try { + const commands: TerminalQuickCommand[] = settings().terminalQuickCommands ?? [] + return { + ok: true, + referencedIds: commands.map((command) => ('agent' in command ? command.agent : null)) + } + } catch { + return { ok: false } + } + } + }) + index.register({ + owner: 'commit-message', + scan: () => { + try { + return { + ok: true, + referencedIds: [settings().commitMessageAi?.agentId, settings().sourceControlAi?.agentId] + } + } catch { + return { ok: false } + } + } + }) + index.register({ + owner: 'source-control-recipe', + scan: () => { + try { + const references: unknown[] = [] + const actions = settings().sourceControlAi?.actions + if (actions) { + for (const action of Object.values(actions)) { + if (action && typeof action === 'object' && 'agentId' in action) { + references.push((action as { agentId?: unknown }).agentId) + } + } + } + // Repo-scoped Source Control overrides are persisted per repo. + for (const repo of store.getRepos()) { + const overrides = repo.sourceControlAi?.actionOverrides + if (!overrides) { + continue + } + for (const override of Object.values(overrides)) { + if (override && typeof override === 'object' && 'agentId' in override) { + references.push((override as { agentId?: unknown }).agentId) + } + } + } + return { ok: true, referencedIds: references } + } catch { + return { ok: false } + } + } + }) + index.register({ + owner: 'automation', + scan: () => { + try { + const references: unknown[] = store + .listAutomations() + .map((automation) => automation.agentId) + // U6: a persisted run's structured launch failure records the requested + // identity, which survives even if the definition's agent later changes, + // so a deleted custom id stays retained while any run failure names it. + for (const run of store.listAutomationRuns()) { + references.push(run.agentLaunchFailure?.requestedAgent) + } + return { ok: true, referencedIds: references } + } catch { + return { ok: false } + } + } + }) + index.register({ + owner: 'workspace', + scan: () => { + try { + // A two-stage creation records the pinned requested identity on both the + // in-flight pending launch and the durable post-create failure, so a + // tombstone stays retained until neither still points at the custom id. + const references: unknown[] = [] + for (const meta of Object.values(store.getAllWorktreeMeta())) { + references.push(meta.pendingAgentLaunch?.requestedAgent) + references.push(meta.agentLaunchFailure?.requestedAgent) + } + return { ok: true, referencedIds: references } + } catch { + return { ok: false } + } + } + }) + index.register({ + // §266 `session` = AI Vault/workspace plus sleeping/resumable sessions. The + // host-private record store is the resume authority: every bound resumable + // session registers its requested identity there and the record survives pane + // dispose, so it is the complete source of custom-id session references. + // AI Vault sessions are disk-discovered and hold no persisted catalog id. + owner: 'session', + scan: () => { + try { + return { + ok: true, + referencedIds: getHostAgentSessionRecordStore().referencedRequestedAgents() + } + } catch { + return { ok: false } + } + } + }) + index.register({ + // §266/§217 `background` = generic unattended launches with no automation run + // or orchestration dispatch to own them. Each attempt records its requested + // identity, and a forgotten attempt still references it until pruned, so a + // deleted custom id's tombstone stays retained while any attempt names it. + owner: 'background', + scan: () => { + try { + return { + ok: true, + referencedIds: getHostBackgroundAgentLaunchStore().referencedRequestedAgents() + } + } catch { + return { ok: false } + } + } + }) +} + +// Why: the orchestration dispatch store is per-runtime (not a host singleton like +// session/background), so its scanner registers from the runtime rather than the +// built-in pass. The guard keeps that registration idempotent even if several +// runtimes share one catalog service (its store), so a shared index never +// double-counts a dispatch reference. +const orchestrationScannerRegistered = new WeakSet() + +/** §266/§217 `orchestration` = coordinator worker dispatches. Each dispatch row + * records its requested identity, so a deleted custom id's tombstone stays + * retained while any dispatch still names it. `referencedRequestedAgents` must + * read the durable dispatch store (surviving reload); a read failure returns + * `ok:false` so the tombstone is conservatively retained. */ +export function registerOrchestrationOwnerScanner( + index: AgentTombstoneReferenceIndex, + referencedRequestedAgents: () => Iterable +): void { + if (orchestrationScannerRegistered.has(index)) { + return + } + orchestrationScannerRegistered.add(index) + index.register({ + owner: 'orchestration', + scan: () => { + try { + return { ok: true, referencedIds: [...referencedRequestedAgents()] } + } catch { + return { ok: false } + } + } + }) +} diff --git a/src/main/agent-launch/agent-catalog-projections.test.ts b/src/main/agent-launch/agent-catalog-projections.test.ts new file mode 100644 index 00000000000..c7f14080eae --- /dev/null +++ b/src/main/agent-launch/agent-catalog-projections.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgent, CustomTuiAgentId, GlobalSettings } from '../../shared/types' +import { + buildAgentCatalogSnapshot, + buildLocalAgentCatalogSnapshot, + measureLocalAgentCatalogStorage, + projectLegacyDefaultTuiAgent, + projectLegacyDisabledTuiAgents +} from './agent-catalog-projections' +import { AgentCatalogRepairTokenRegistry } from './agent-catalog-mutations' +import { scanForCustomEnvLeak } from '../../shared/custom-env-leak-scan' + +const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' +const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' + +function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +function liveAgent(overrides: Partial = {}): CustomTuiAgent { + return { + id: customId('codex'), + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +function settingsWith(overrides: Partial = {}): GlobalSettings { + return { + defaultTuiAgent: 'auto', + disabledTuiAgents: [], + customTuiAgents: [], + deletedCustomTuiAgents: [], + agentCatalogRevision: 2, + ...overrides + } as GlobalSettings +} + +describe('remote snapshot projection', () => { + it('projects ready rows env-free with the conservative availability hint', () => { + const withheld = liveAgent({ env: { KEY: 'secret-value' }, syncEnv: false }) + const available = liveAgent({ + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Shared', + env: { TOKEN: 'another-secret' }, + syncEnv: true + }) + const snapshot = buildAgentCatalogSnapshot( + settingsWith({ customTuiAgents: [withheld, available] }) + ) + expect('code' in snapshot).toBe(false) + if ('code' in snapshot) { + return + } + const text = JSON.stringify(snapshot) + expect(text).not.toContain('secret-value') + expect(text).not.toContain('another-secret') + expect(text).not.toContain('KEY') + expect(text).not.toContain('TOKEN') + const [first, second] = snapshot.customAgents + expect(first).toMatchObject({ + status: 'ready', + envState: 'withheld', + availabilityCheck: 'baseline-detection' + }) + expect(second).toMatchObject({ + status: 'ready', + envState: 'available', + availabilityCheck: 'host-preflight' + }) + }) + + it('uses host-preflight for a configured executable regardless of env', () => { + const snapshot = buildAgentCatalogSnapshot( + settingsWith({ customTuiAgents: [liveAgent({ commandOverride: '/opt/codex' })] }) + ) + if ('code' in snapshot) { + throw new Error('unexpected projection error') + } + expect(snapshot.customAgents[0]).toMatchObject({ availabilityCheck: 'host-preflight' }) + }) + + it('projects valid-id repair rows without raw fields and omits malformed/duplicate rows', () => { + const repairRow = { ...liveAgent(), label: '', args: '"unclosed' } + const malformed = { id: 'custom-agent:codex:nope', baseAgent: 'codex', label: 'Bad' } + const duplicateId = customId('claude', UUID_B) + const dupA = liveAgent({ id: duplicateId, baseAgent: 'claude', label: 'Dup A' }) + const dupB = liveAgent({ id: duplicateId, baseAgent: 'claude', label: 'Dup B' }) + const snapshot = buildAgentCatalogSnapshot( + settingsWith({ + customTuiAgents: [repairRow, malformed as unknown as CustomTuiAgent, dupA, dupB] + }) + ) + if ('code' in snapshot) { + throw new Error('unexpected projection error') + } + expect(snapshot.customAgents).toHaveLength(1) + expect(snapshot.customAgents[0]).toMatchObject({ + id: repairRow.id, + status: 'repair-required', + label: null, + envState: 'none' + }) + expect(JSON.stringify(snapshot)).not.toContain('unclosed') + }) + + it('returns the typed projection error above 512 KiB while keeping version and revision', () => { + // ~200 agents x ~4 KiB args ≈ >512 KiB serialized (args are projected). + const agents: CustomTuiAgent[] = [] + for (let i = 0; i < 200; i += 1) { + agents.push( + liveAgent({ + id: `custom-agent:codex:${UUID_A.slice(0, 34)}${String(i % 100).padStart(2, '0')}` as CustomTuiAgentId, + label: `Agent ${i}`, + args: `--marker ${'x'.repeat(4000)}` + }) + ) + } + // Ensure unique canonical ids (vary last two hex chars). + const unique = agents.map((agent, index) => ({ + ...agent, + id: `custom-agent:codex:${UUID_A.slice(0, -4)}${index.toString(16).padStart(4, '0')}` as CustomTuiAgentId + })) + const snapshot = buildAgentCatalogSnapshot(settingsWith({ customTuiAgents: unique })) + expect(snapshot).toMatchObject({ + version: 1, + revision: 2, + code: 'agent_catalog_payload_too_large', + maxBytes: 524_288 + }) + }) + + it('replaces an invalid tombstone label with an empty string for remote fallback copy', () => { + const snapshot = buildAgentCatalogSnapshot( + settingsWith({ + deletedCustomTuiAgents: [ + { id: customId('codex'), baseAgent: 'codex', label: ' ', deletedAt: 1 } + ] + }) + ) + if ('code' in snapshot) { + throw new Error('unexpected projection error') + } + expect(snapshot.deletedCustomAgents[0].label).toBe('') + }) +}) + +describe('local snapshot projection', () => { + it('summarizes env numerically, mints repair tokens, and reports both budgets', () => { + const live = liveAgent({ env: { KEY: 'secret-value' } }) + const malformed = { id: 'custom-agent:codex:nope', label: 'Bad' } + const registry = new AgentCatalogRepairTokenRegistry() + const snapshot = buildLocalAgentCatalogSnapshot( + settingsWith({ customTuiAgents: [live, malformed as unknown as CustomTuiAgent] }), + registry + ) + expect(JSON.stringify(snapshot)).not.toContain('secret-value') + const ready = snapshot.customAgents.find((row) => row.status === 'ready') + expect(ready && ready.status === 'ready' ? ready.envSummary.entryCount : -1).toBe(1) + const repair = snapshot.customAgents.find((row) => row.status === 'repair-required') + expect( + repair && repair.status === 'repair-required' ? repair.repairToken.length : 0 + ).toBeGreaterThan(0) + expect(snapshot.projection.status).toBe('ready') + expect(snapshot.localStorage.status).toBe('ready') + }) + + it('labels desktop rows configured-executable, custom-path, or baseline-stock', () => { + // Desktop-only status source (G8): a configured executable and an accepted + // PATH override each defeat baseline stock detection and must be told apart + // from a plain stock-prefix row so the UI shows the right status. + const configured = liveAgent({ commandOverride: '/usr/local/bin/codex' }) + const customPath = liveAgent({ + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Claude PATH', + env: { PATH: '/opt/tools/bin' } + }) + const baselineStock = liveAgent({ + id: customId('gemini'), + baseAgent: 'gemini', + label: 'Plain Gemini' + }) + const snapshot = buildLocalAgentCatalogSnapshot( + settingsWith({ customTuiAgents: [configured, customPath, baselineStock] }), + new AgentCatalogRepairTokenRegistry() + ) + const reasonById = new Map( + snapshot.customAgents.flatMap((row) => + row.status === 'ready' ? [[row.definition.id, row.availabilityReason]] : [] + ) + ) + expect(reasonById.get(configured.id)).toBe('configured-executable') + expect(reasonById.get(customPath.id)).toBe('custom-path') + expect(reasonById.get(baselineStock.id)).toBe('baseline-stock') + }) + + it('keeps repair tokens stable across unrelated revisions', () => { + const malformed = { id: 'custom-agent:codex:nope', label: 'Bad' } + const registry = new AgentCatalogRepairTokenRegistry() + const first = buildLocalAgentCatalogSnapshot( + settingsWith({ customTuiAgents: [malformed as unknown as CustomTuiAgent] }), + registry + ) + const second = buildLocalAgentCatalogSnapshot( + settingsWith({ + customTuiAgents: [malformed as unknown as CustomTuiAgent], + agentCatalogRevision: 3 + }), + registry + ) + const tokenOf = (snapshot: typeof first): string => { + const row = snapshot.customAgents[0] + return row.status === 'repair-required' ? row.repairToken : '' + } + expect(tokenOf(first)).toBe(tokenOf(second)) + }) + + it('measures the 16 MiB local storage budget over the full env-bearing catalog', () => { + const status = measureLocalAgentCatalogStorage(settingsWith({ customTuiAgents: [liveAgent()] })) + expect(status.status).toBe('ready') + expect(status.maxBytes).toBe(16_777_216) + }) +}) + +describe('no custom env leaks recursively (G7 oracle-12/13)', () => { + // Deliberately distinctive so a match cannot come from a legitimate id/label/arg. + const ENV_KEY_A = 'ZZLEAKKEY_ALPHA' + const ENV_VALUE_A = 'zzleakvalue_alpha_9f3' + const ENV_KEY_B = 'ZZLEAKKEY_BETA' + const ENV_VALUE_B = 'zzleakvalue_beta_7c1' + const FORBIDDEN = [ENV_KEY_A, ENV_VALUE_A, ENV_KEY_B, ENV_VALUE_B] + + function envBearingSettings(): GlobalSettings { + return settingsWith({ + customTuiAgents: [ + // available (syncEnv on) — the case most at risk of leaking through env + // application metadata. + liveAgent({ env: { [ENV_KEY_A]: ENV_VALUE_A }, syncEnv: true }), + // withheld (syncEnv off). + liveAgent({ + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Withheld', + env: { [ENV_KEY_B]: ENV_VALUE_B }, + syncEnv: false + }) + ] + }) + } + + it('remote snapshot projection carries no env key or value at any depth', () => { + const snapshot = buildAgentCatalogSnapshot(envBearingSettings()) + if ('code' in snapshot) { + throw new Error('unexpected projection error') + } + expect(scanForCustomEnvLeak(snapshot, FORBIDDEN)).toEqual([]) + }) + + it('local snapshot projection carries no env key or value at any depth', () => { + const snapshot = buildLocalAgentCatalogSnapshot( + envBearingSettings(), + new AgentCatalogRepairTokenRegistry() + ) + // The env-numeric summary must survive so the scan is meaningful (env present). + const ready = snapshot.customAgents.find((row) => row.status === 'ready') + expect(ready && ready.status === 'ready' ? ready.envSummary.entryCount : 0).toBe(1) + expect(scanForCustomEnvLeak(snapshot, FORBIDDEN)).toEqual([]) + }) +}) + +describe('legacy client projections', () => { + it('maps defaults so old clients never see custom ids or non-Auto null', () => { + expect(projectLegacyDefaultTuiAgent('codex')).toBe('codex') + expect(projectLegacyDefaultTuiAgent('auto')).toBeNull() + expect(projectLegacyDefaultTuiAgent('blank')).toBe('blank') + expect(projectLegacyDefaultTuiAgent(null)).toBe('blank') + expect(projectLegacyDefaultTuiAgent(customId('codex'))).toBe('blank') + }) + + it('drops custom ids from the legacy disabled list', () => { + expect(projectLegacyDisabledTuiAgents(['codex', customId('claude', UUID_B)])).toEqual(['codex']) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-projections.ts b/src/main/agent-launch/agent-catalog-projections.ts new file mode 100644 index 00000000000..a64e3771225 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-projections.ts @@ -0,0 +1,259 @@ +// Env-free projections of the agent catalog: the revisioned remote snapshot +// synced to mobile/paired clients, the local (preload-IPC-only) repair summary, +// and the legacy `settings` compatibility projection for pre-catalog clients. +// No projection built here may contain a custom env key or value. + +import type { CustomTuiAgent, GlobalSettings, TuiAgent } from '../../shared/types' +import type { + AgentCatalogProjectionError, + AgentCatalogSnapshot, + AgentProjectionStatus, + LocalAgentCatalogSnapshot, + LocalAgentCatalogStorageStatus, + LocalCustomTuiAgent, + SyncedCustomTuiAgent +} from '../../shared/agent-catalog-snapshot' +import { MAX_LOCAL_AGENT_DRAFT_BYTES } from '../../shared/agent-catalog-snapshot' +import { + MAX_AGENT_CATALOG_PROJECTION_BYTES, + MAX_LOCAL_AGENT_CATALOG_BYTES, + measureCustomAgentEnvBytes, + normalizeAgentCatalog, + utf8ByteLength, + validateAgentLabel, + type AgentCatalog, + type CorruptCatalogRow +} from '../../shared/custom-tui-agents' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import type { AgentCatalogRepairTokenRegistry } from './agent-catalog-mutations' + +export function normalizeCatalogFromSettings(settings: GlobalSettings): AgentCatalog { + return normalizeAgentCatalog({ + customTuiAgents: settings.customTuiAgents, + deletedCustomTuiAgents: settings.deletedCustomTuiAgents, + disabledTuiAgents: settings.disabledTuiAgents, + defaultTuiAgent: settings.defaultTuiAgent + }).catalog +} + +function remoteEnvState(definition: CustomTuiAgent): 'none' | 'available' | 'withheld' { + if (Object.keys(definition.env).length === 0) { + return 'none' + } + return definition.syncEnv ? 'available' : 'withheld' +} + +function hasCustomPathOverride(definition: CustomTuiAgent): boolean { + return Object.keys(definition.env).some((key) => key.toLowerCase() === 'path') +} + +function syncedRow(definition: CustomTuiAgent): SyncedCustomTuiAgent { + const envState = remoteEnvState(definition) + return { + id: definition.id, + baseAgent: definition.baseAgent, + label: definition.label, + ...(definition.commandOverride ? { commandOverride: definition.commandOverride } : {}), + args: definition.args, + syncEnv: definition.syncEnv, + status: 'ready', + envState, + // Conservative: a configured executable or host-applicable env means stock + // baseline detection cannot vouch for this row, and naming the actual + // reason (e.g. PATH) would leak which env key exists. + availabilityCheck: + definition.commandOverride || envState === 'available' + ? 'host-preflight' + : 'baseline-detection' + } +} + +function syncedRepairRow(row: CorruptCatalogRow): SyncedCustomTuiAgent | null { + // Only rows with an independently valid unique id and base may project; the + // raw invalid command/args/env never leave the host. + if (!row.id || !row.baseAgent) { + return null + } + return { + id: row.id, + baseAgent: row.baseAgent, + label: row.label !== null && !validateAgentLabel(row.label) ? row.label : null, + status: 'repair-required', + envState: 'none' + } +} + +export function buildAgentCatalogSnapshot( + settings: GlobalSettings, + catalog: AgentCatalog = normalizeCatalogFromSettings(settings) +): AgentCatalogSnapshot | AgentCatalogProjectionError { + const revision = settings.agentCatalogRevision ?? 1 + const customAgents: SyncedCustomTuiAgent[] = [] + for (const definition of catalog.liveCustomAgents) { + customAgents.push(syncedRow(definition)) + } + for (const row of catalog.repairRequiredById.values()) { + const projected = syncedRepairRow(row) + if (projected) { + customAgents.push(projected) + } + } + // Malformed/duplicate identity rows exist only in the local snapshot. + const snapshot: AgentCatalogSnapshot = { + version: 1, + revision, + defaultAgent: catalog.defaultAgent, + disabledAgents: [...catalog.disabledAgents], + customAgents, + deletedCustomAgents: [...catalog.tombstonesById.values()].map((tombstone) => ({ + ...tombstone, + // Remote clients localize a generic fallback for an unsafe label rather + // than receiving the raw invalid text. + label: validateAgentLabel(tombstone.label) ? '' : tombstone.label + })) + } + const bytes = utf8ByteLength(JSON.stringify(snapshot)) + if (bytes > MAX_AGENT_CATALOG_PROJECTION_BYTES) { + return { + version: 1, + revision, + code: 'agent_catalog_payload_too_large', + maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES + } + } + return snapshot +} + +export function measureAgentCatalogProjection( + settings: GlobalSettings, + catalog: AgentCatalog = normalizeCatalogFromSettings(settings) +): AgentProjectionStatus { + const revision = settings.agentCatalogRevision ?? 1 + const customAgents: SyncedCustomTuiAgent[] = catalog.liveCustomAgents.map(syncedRow) + for (const row of catalog.repairRequiredById.values()) { + const projected = syncedRepairRow(row) + if (projected) { + customAgents.push(projected) + } + } + const snapshot: AgentCatalogSnapshot = { + version: 1, + revision, + defaultAgent: catalog.defaultAgent, + disabledAgents: [...catalog.disabledAgents], + customAgents, + deletedCustomAgents: [...catalog.tombstonesById.values()] + } + const bytes = utf8ByteLength(JSON.stringify(snapshot)) + return bytes > MAX_AGENT_CATALOG_PROJECTION_BYTES + ? { status: 'too-large', bytes, maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES } + : { status: 'ready', bytes, maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES } +} + +/** Complete UTF-8 JSON size of the persisted live+tombstone custom catalog, + * including env (the 16 MiB local storage budget). */ +export function measureLocalAgentCatalogStorage( + settings: GlobalSettings +): LocalAgentCatalogStorageStatus { + const bytes = utf8ByteLength( + JSON.stringify({ + customTuiAgents: settings.customTuiAgents ?? [], + deletedCustomTuiAgents: settings.deletedCustomTuiAgents ?? [] + }) + ) + return bytes > MAX_LOCAL_AGENT_CATALOG_BYTES + ? { status: 'too-large', bytes, maxBytes: MAX_LOCAL_AGENT_CATALOG_BYTES } + : { status: 'ready', bytes, maxBytes: MAX_LOCAL_AGENT_CATALOG_BYTES } +} + +function localReadyRow(definition: CustomTuiAgent): LocalCustomTuiAgent { + const { env, ...definitionWithoutEnv } = definition + return { + status: 'ready', + definition: definitionWithoutEnv, + envSummary: { + entryCount: Object.keys(env).length, + bytes: measureCustomAgentEnvBytes(env) + }, + availabilityReason: definition.commandOverride + ? 'configured-executable' + : hasCustomPathOverride(definition) + ? 'custom-path' + : 'baseline-stock' + } +} + +function localRepairRow( + row: CorruptCatalogRow, + repairTokens: AgentCatalogRepairTokenRegistry +): LocalCustomTuiAgent { + return { + status: 'repair-required', + ...(row.id ? { id: row.id } : {}), + ...(row.baseAgent ? { baseAgent: row.baseAgent } : {}), + label: row.label, + repairToken: repairTokens.tokenFor(row), + issues: row.issues.map((issue) => ({ + // Identity/baseAgent issues map onto the repair-issue DTO field names. + field: issue.field, + reason: issue.reason, + ...(issue.envEntryIndex !== undefined ? { envEntryIndex: issue.envEntryIndex } : {}) + })), + rawBytes: row.rawBytes, + draftAvailability: row.rawBytes > MAX_LOCAL_AGENT_DRAFT_BYTES ? 'too-large' : 'available' + } +} + +export function buildLocalAgentCatalogSnapshot( + settings: GlobalSettings, + repairTokens: AgentCatalogRepairTokenRegistry, + catalog: AgentCatalog = normalizeCatalogFromSettings(settings) +): LocalAgentCatalogSnapshot { + const revision = settings.agentCatalogRevision ?? 1 + const customAgents: LocalCustomTuiAgent[] = [] + for (const definition of catalog.liveCustomAgents) { + customAgents.push(localReadyRow(definition)) + } + for (const row of catalog.repairRequiredById.values()) { + customAgents.push(localRepairRow(row, repairTokens)) + } + for (const row of catalog.corruptRows) { + customAgents.push(localRepairRow(row, repairTokens)) + } + const repairIssues = customAgents.flatMap((row) => + row.status === 'repair-required' ? row.issues : [] + ) + return { + version: 1, + revision, + defaultAgent: catalog.defaultAgent, + disabledAgents: [...catalog.disabledAgents], + customAgents, + deletedCustomAgents: [...catalog.tombstonesById.values()], + repairIssues, + projection: measureAgentCatalogProjection(settings, catalog), + localStorage: measureLocalAgentCatalogStorage(settings) + } +} + +/** Legacy `settings.defaultTuiAgent` projection for pre-catalog clients: an old + * client must never receive a custom id (it cannot represent it) nor legacy + * null for anything but Auto (null meant auto-launch). A custom, tombstoned, + * or repair-needed default projects Blank — never its base — so a safe custom + * default cannot become a built-in launch inheriting global/YOLO args. */ +export function projectLegacyDefaultTuiAgent( + defaultAgent: TuiAgent | 'auto' | 'blank' | null | undefined +): TuiAgent | 'blank' | null { + if (defaultAgent === 'auto') { + return null + } + if (defaultAgent === 'blank' || defaultAgent === null || defaultAgent === undefined) { + return 'blank' + } + return isBuiltInTuiAgent(defaultAgent) ? defaultAgent : 'blank' +} + +/** Legacy disabled-list projection: omit custom ids an old client cannot render. */ +export function projectLegacyDisabledTuiAgents(disabled: readonly TuiAgent[]): TuiAgent[] { + return disabled.filter((agent) => isBuiltInTuiAgent(agent)) +} diff --git a/src/main/agent-launch/agent-catalog-repair-mutations.test.ts b/src/main/agent-launch/agent-catalog-repair-mutations.test.ts new file mode 100644 index 00000000000..47bbbe82bfd --- /dev/null +++ b/src/main/agent-launch/agent-catalog-repair-mutations.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgent, CustomTuiAgentId, GlobalSettings } from '../../shared/types' +import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot' +import { normalizeAgentCatalog } from '../../shared/custom-tui-agents' +import { + AgentCatalogRepairTokenRegistry, + applyAgentCatalogMutation, + type ApplyAgentCatalogMutationArgs +} from './agent-catalog-mutations' + +const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' + +function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +function liveAgent(overrides: Partial = {}): CustomTuiAgent { + return { + id: customId('codex'), + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +function draft(overrides: Partial = {}): CustomAgentDraft { + return { + label: 'New Agent', + commandOverride: null, + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +function settingsWith(overrides: Partial = {}): GlobalSettings { + return { + defaultTuiAgent: 'auto', + disabledTuiAgents: [], + customTuiAgents: [], + deletedCustomTuiAgents: [], + agentCatalogRevision: 5, + agentCmdOverrides: {}, + ...overrides + } as GlobalSettings +} + +function apply( + overrides: Partial & { + mutation: ApplyAgentCatalogMutationArgs['request']['mutation'] + expectedRevision?: number + } +) { + const { mutation, expectedRevision, ...rest } = overrides + return applyAgentCatalogMutation({ + settings: settingsWith(), + currentRevision: 5, + repairTokens: new AgentCatalogRepairTokenRegistry(), + countTombstoneReferences: () => 0, + ...rest, + request: { expectedRevision: expectedRevision ?? 5, mutation } + }) +} + +function corruptRowsOf(settings: GlobalSettings) { + return normalizeAgentCatalog({ + customTuiAgents: settings.customTuiAgents, + deletedCustomTuiAgents: settings.deletedCustomTuiAgents, + disabledTuiAgents: settings.disabledTuiAgents, + defaultTuiAgent: settings.defaultTuiAgent + }).catalog.corruptRows +} + +describe('repair-corrupt', () => { + function corruptSettings() { + // A malformed id cannot be addressed by id: identity-empty corrupt row. + const malformed = { + id: 'custom-agent:codex:not-a-uuid', + baseAgent: 'codex', + label: 'Bad', + args: '', + env: {}, + syncEnv: false + } + return settingsWith({ customTuiAgents: [malformed as unknown as CustomTuiAgent] }) + } + + it('discard removes only the selected physical row', () => { + const settings = corruptSettings() + const registry = new AgentCatalogRepairTokenRegistry() + const rows = corruptRowsOf(settings) + expect(rows).toHaveLength(1) + const token = registry.tokenFor(rows[0]) + const result = apply({ + settings, + repairTokens: registry, + mutation: { kind: 'repair-corrupt', repairToken: token, action: { kind: 'discard' } } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.customTuiAgents).toEqual([]) + }) + + it('replace mints a new id in place and never tombstones the untrusted old id', () => { + const settings = corruptSettings() + const registry = new AgentCatalogRepairTokenRegistry() + const token = registry.tokenFor(corruptRowsOf(settings)[0]) + const result = apply({ + settings, + repairTokens: registry, + mutation: { + kind: 'repair-corrupt', + repairToken: token, + action: { kind: 'replace', baseAgent: 'claude', draft: draft({ label: 'Replaced' }) } + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.customTuiAgents).toHaveLength(1) + expect(result.patch.customTuiAgents?.[0]).toMatchObject({ + baseAgent: 'claude', + label: 'Replaced' + }) + expect(result.patch.customTuiAgents?.[0].id).toBe(result.mintedId) + expect(result.patch.deletedCustomTuiAgents).toBeUndefined() + }) + + it('rejects stale tokens without writing', () => { + const settings = corruptSettings() + const result = apply({ + settings, + mutation: { kind: 'repair-corrupt', repairToken: 'stale', action: { kind: 'discard' } } + }) + expect(result).toEqual({ ok: false, code: 'stale_agent_repair_token' }) + }) + + it('rejects single-row repair for duplicate-id rows', () => { + const id = customId('codex') + const settings = settingsWith({ + customTuiAgents: [liveAgent({ id, label: 'One' }), liveAgent({ id, label: 'Two' })] + }) + const registry = new AgentCatalogRepairTokenRegistry() + const rows = corruptRowsOf(settings) + const token = registry.tokenFor(rows[0]) + const result = apply({ + settings, + repairTokens: registry, + mutation: { kind: 'repair-corrupt', repairToken: token, action: { kind: 'discard' } } + }) + expect(result).toMatchObject({ ok: false, reason: 'duplicate_id' }) + }) +}) + +describe('resolve-duplicate-id', () => { + const id = customId('codex') + function duplicateSettings() { + return settingsWith({ + customTuiAgents: [liveAgent({ id, label: 'One' }), liveAgent({ id, label: 'Two' })] + }) + } + + it('commits the whole group atomically with at most one kept canonical row', () => { + const settings = duplicateSettings() + const registry = new AgentCatalogRepairTokenRegistry() + const rows = corruptRowsOf(settings) + expect(rows).toHaveLength(2) + const result = apply({ + settings, + repairTokens: registry, + mutation: { + kind: 'resolve-duplicate-id', + duplicateId: id, + rows: [ + { + repairToken: registry.tokenFor(rows[0]), + action: { + kind: 'keep-for-existing-references', + repairedDraft: draft({ label: 'Kept' }) + } + }, + { + repairToken: registry.tokenFor(rows[1]), + action: { kind: 'replace', baseAgent: 'codex', draft: draft({ label: 'Split Off' }) } + } + ] + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const live = result.patch.customTuiAgents ?? [] + expect(live).toHaveLength(2) + expect(live[0]).toMatchObject({ id, label: 'Kept' }) + expect(live[1].id).not.toBe(id) + expect(live[1]).toMatchObject({ label: 'Split Off' }) + }) + + it('allows resolving with no kept row, leaving the old id unknown', () => { + const settings = duplicateSettings() + const registry = new AgentCatalogRepairTokenRegistry() + const rows = corruptRowsOf(settings) + const result = apply({ + settings, + repairTokens: registry, + mutation: { + kind: 'resolve-duplicate-id', + duplicateId: id, + rows: [ + { repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } }, + { repairToken: registry.tokenFor(rows[1]), action: { kind: 'discard' } } + ] + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.patch.customTuiAgents).toEqual([]) + }) + + it('rejects an incomplete group, repeated tokens, or two keeps', () => { + const settings = duplicateSettings() + const registry = new AgentCatalogRepairTokenRegistry() + const rows = corruptRowsOf(settings) + const incomplete = apply({ + settings, + repairTokens: registry, + mutation: { + kind: 'resolve-duplicate-id', + duplicateId: id, + rows: [{ repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } }] + } + }) + expect(incomplete).toEqual({ ok: false, code: 'stale_agent_repair_token' }) + + const repeated = apply({ + settings, + repairTokens: registry, + mutation: { + kind: 'resolve-duplicate-id', + duplicateId: id, + rows: [ + { repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } }, + { repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } } + ] + } + }) + expect(repeated).toEqual({ ok: false, code: 'stale_agent_repair_token' }) + + const twoKeeps = apply({ + settings, + repairTokens: registry, + mutation: { + kind: 'resolve-duplicate-id', + duplicateId: id, + rows: [ + { + repairToken: registry.tokenFor(rows[0]), + action: { kind: 'keep-for-existing-references', repairedDraft: draft({ label: 'A' }) } + }, + { + repairToken: registry.tokenFor(rows[1]), + action: { kind: 'keep-for-existing-references', repairedDraft: draft({ label: 'B' }) } + } + ] + } + }) + expect(twoKeeps).toMatchObject({ ok: false, code: 'invalid_agent_field' }) + }) + + it('applies nothing when one row in the group is invalid (oracle 36)', () => { + // Failure-side atomicity: the first row is fully valid and would be kept, but + // the second row's draft is invalid. The mutation must reject wholesale with + // no patch — the valid row's mid-loop accumulation is never committed. + const settings = duplicateSettings() + const registry = new AgentCatalogRepairTokenRegistry() + const rows = corruptRowsOf(settings) + const result = apply({ + settings, + repairTokens: registry, + mutation: { + kind: 'resolve-duplicate-id', + duplicateId: id, + rows: [ + { + repairToken: registry.tokenFor(rows[0]), + action: { + kind: 'keep-for-existing-references', + repairedDraft: draft({ label: 'Kept' }) + } + }, + { + repairToken: registry.tokenFor(rows[1]), + action: { kind: 'replace', baseAgent: 'codex', draft: draft({ label: '' }) } + } + ] + } + }) + // field:'label' pins the failure to row1's draft validation, not row0's + // parsedBase guard (which returns invalid_agent_field with no field) — so this + // can only pass if row0 was accepted mid-loop and then discarded on reject. + expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field', field: 'label' }) + expect((result as { patch?: unknown }).patch).toBeUndefined() + }) +}) diff --git a/src/main/agent-launch/agent-catalog-repair-mutations.ts b/src/main/agent-launch/agent-catalog-repair-mutations.ts new file mode 100644 index 00000000000..1e8e89d2b80 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-repair-mutations.ts @@ -0,0 +1,232 @@ +// Corrupt-row repair mutations and the repair-token registry. Repair tokens are +// revision-scoped, per-physical-record handles; duplicate-id groups resolve +// atomically. Never persisted, synced, or logged. + +import { createHash } from 'node:crypto' +import type { BuiltInTuiAgent, CustomTuiAgent, CustomTuiAgentId } from '../../shared/types' +import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot' +import { + mintCustomTuiAgentId, + normalizeAgentLabelKey, + type CorruptCatalogRow +} from '../../shared/custom-tui-agents' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import { + draftToDefinition, + labelCollides, + validateDraft, + type AgentCatalogMutationApplication +} from './agent-catalog-draft-validation' +import type { MutationContext } from './agent-catalog-mutations' + +/** Repair tokens are minted per physical corrupt record and stay stable while + * that record (content and position) is unchanged, so editor focus/drafts do + * not remount on unrelated revisions. They are never persisted, synced, or + * logged, and resolve only with the exact current catalog revision. */ +export class AgentCatalogRepairTokenRegistry { + private readonly tokensByRecordKey = new Map() + + private recordKey(row: CorruptCatalogRow): string { + const contentHash = createHash('sha256') + .update(JSON.stringify(row.raw) ?? 'null') + .digest('hex') + return `${contentHash}:${row.physicalIndex}` + } + + tokenFor(row: CorruptCatalogRow): string { + const key = this.recordKey(row) + const existing = this.tokensByRecordKey.get(key) + if (existing) { + return existing + } + const token = createHash('sha256') + .update(`${key}:${crypto.randomUUID()}`) + .digest('hex') + .slice(0, 32) + this.tokensByRecordKey.set(key, token) + return token + } + + resolve(token: string, rows: readonly CorruptCatalogRow[]): CorruptCatalogRow | null { + for (const row of rows) { + if (this.tokenFor(row) === token) { + return row + } + } + return null + } +} + +export type RepairContext = MutationContext & { repairTokens: AgentCatalogRepairTokenRegistry } + +export function applyRepairCorrupt( + repairToken: string, + action: + | { kind: 'discard' } + | { kind: 'replace'; baseAgent: BuiltInTuiAgent; draft: CustomAgentDraft }, + context: RepairContext +): AgentCatalogMutationApplication { + const row = context.repairTokens.resolve(repairToken, context.catalog.corruptRows) + if (!row) { + return { ok: false, code: 'stale_agent_repair_token' } + } + // Duplicate-id rows reject single-row repair: the group must resolve at once. + if (row.issues.some((issue) => issue.reason === 'duplicate_id')) { + return { ok: false, code: 'invalid_agent_field', reason: 'duplicate_id' } + } + const nextLive = [...context.persistedLive] + if (row.physicalIndex < 0 || row.physicalIndex >= nextLive.length) { + return { ok: false, code: 'stale_agent_repair_token' } + } + if (action.kind === 'discard') { + nextLive.splice(row.physicalIndex, 1) + return { + ok: true, + patch: { + customTuiAgents: nextLive as CustomTuiAgent[], + agentCatalogRevision: context.newRevision + }, + newRevision: context.newRevision, + prunedTombstoneIds: [] + } + } + if (!isBuiltInTuiAgent(action.baseAgent)) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const draftError = validateDraft(action.draft) + if (draftError) { + return draftError + } + const retained = context.persistedTombstones.filter( + (tombstone) => context.args.countTombstoneReferences(tombstone.id) !== 0 + ) + const candidateKey = normalizeAgentLabelKey(action.draft.label) + if (labelCollides(candidateKey, context.catalog, retained)) { + return { ok: false, code: 'duplicate_agent_label', field: 'label' } + } + // Replace mints a new canonical id in the same physical slot for stable + // visual order; it never creates a tombstone for the untrusted old id and + // never rebinds any reference. + const id = mintCustomTuiAgentId(action.baseAgent) + nextLive.splice(row.physicalIndex, 1, draftToDefinition(id, action.baseAgent, action.draft)) + return { + ok: true, + patch: { + customTuiAgents: nextLive as CustomTuiAgent[], + agentCatalogRevision: context.newRevision + }, + newRevision: context.newRevision, + mintedId: id, + prunedTombstoneIds: [] + } +} + +export function applyResolveDuplicateId( + duplicateId: CustomTuiAgentId, + rows: readonly { + repairToken: string + action: + | { kind: 'keep-for-existing-references'; repairedDraft: CustomAgentDraft } + | { kind: 'discard' } + | { kind: 'replace'; baseAgent: BuiltInTuiAgent; draft: CustomAgentDraft } + }[], + context: RepairContext +): AgentCatalogMutationApplication { + const groupRows = context.catalog.corruptRows.filter( + (row) => row.id === duplicateId && row.issues.some((issue) => issue.reason === 'duplicate_id') + ) + if (groupRows.length === 0) { + return { ok: false, code: 'stale_agent_repair_token' } + } + // The submitted tokens must cover the exact current duplicate group once each. + const resolved = new Map() + for (const submitted of rows) { + const row = context.repairTokens.resolve(submitted.repairToken, groupRows) + if (!row || resolved.has(row)) { + return { ok: false, code: 'stale_agent_repair_token' } + } + resolved.set(row, submitted) + } + if (resolved.size !== groupRows.length) { + return { ok: false, code: 'stale_agent_repair_token' } + } + const keeps = rows.filter((row) => row.action.kind === 'keep-for-existing-references') + if (keeps.length > 1) { + return { ok: false, code: 'invalid_agent_field', reason: 'duplicate_id' } + } + + const parsedBase = context.catalog.corruptRows.find((row) => row.id === duplicateId)?.baseAgent + const replacements = new Map() + let mintedId: CustomTuiAgentId | undefined + const retained = context.persistedTombstones.filter( + (tombstone) => context.args.countTombstoneReferences(tombstone.id) !== 0 + ) + const pendingLabels: string[] = [] + for (const [row, submitted] of resolved) { + if (submitted.action.kind === 'discard') { + replacements.set(row.physicalIndex, null) + continue + } + if (submitted.action.kind === 'keep-for-existing-references') { + if (!parsedBase) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const draftError = validateDraft(submitted.action.repairedDraft) + if (draftError) { + return draftError + } + const key = normalizeAgentLabelKey(submitted.action.repairedDraft.label) + if (labelCollides(key, context.catalog, retained) || pendingLabels.includes(key)) { + return { ok: false, code: 'duplicate_agent_label', field: 'label' } + } + pendingLabels.push(key) + // The kept row preserves the old id only after this explicit choice. + replacements.set( + row.physicalIndex, + draftToDefinition(duplicateId, parsedBase, submitted.action.repairedDraft) + ) + continue + } + if (!isBuiltInTuiAgent(submitted.action.baseAgent)) { + return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' } + } + const draftError = validateDraft(submitted.action.draft) + if (draftError) { + return draftError + } + const key = normalizeAgentLabelKey(submitted.action.draft.label) + if (labelCollides(key, context.catalog, retained) || pendingLabels.includes(key)) { + return { ok: false, code: 'duplicate_agent_label', field: 'label' } + } + pendingLabels.push(key) + const id = mintCustomTuiAgentId(submitted.action.baseAgent) + mintedId = id + replacements.set( + row.physicalIndex, + draftToDefinition(id, submitted.action.baseAgent, submitted.action.draft) + ) + } + + const nextLive: unknown[] = [] + context.persistedLive.forEach((row, index) => { + if (!replacements.has(index)) { + nextLive.push(row) + return + } + const replacement = replacements.get(index) + if (replacement !== null && replacement !== undefined) { + nextLive.push(replacement) + } + }) + + return { + ok: true, + patch: { + customTuiAgents: nextLive as CustomTuiAgent[], + agentCatalogRevision: context.newRevision + }, + newRevision: context.newRevision, + ...(mintedId ? { mintedId } : {}), + prunedTombstoneIds: [] + } +} diff --git a/src/main/agent-launch/agent-catalog-schema-migration.test.ts b/src/main/agent-launch/agent-catalog-schema-migration.test.ts new file mode 100644 index 00000000000..557220fd463 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-schema-migration.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, afterEach } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createPinnedPreV1Backup, + migrateAgentCatalogSchema, + pinnedPreV1BackupPath +} from './agent-catalog-schema-migration' + +const tempDirs: string[] = [] + +function makeDataFile(contents: string, mode?: number): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-')) + tempDirs.push(dir) + const dataFile = join(dir, 'orca-data.json') + writeFileSync(dataFile, contents, mode !== undefined ? { mode } : undefined) + return dataFile +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) + +describe('createPinnedPreV1Backup', () => { + it('writes the exact raw bytes with matching permissions', () => { + const raw = '{"settings":{"defaultTuiAgent":null}}' + const dataFile = makeDataFile(raw, 0o600) + const result = createPinnedPreV1Backup(dataFile, raw) + expect(result).toEqual({ ok: true, created: true }) + const backupFile = pinnedPreV1BackupPath(dataFile) + expect(readFileSync(backupFile, 'utf-8')).toBe(raw) + expect(statSync(backupFile).mode & 0o777).toBe(statSync(dataFile).mode & 0o777) + }) + + it('keeps an existing pinned backup instead of overwriting it', () => { + const original = '{"original":true}' + const dataFile = makeDataFile(original) + expect(createPinnedPreV1Backup(dataFile, original)).toEqual({ ok: true, created: true }) + const second = createPinnedPreV1Backup(dataFile, '{"newer":true}') + expect(second).toEqual({ ok: true, created: false }) + expect(readFileSync(pinnedPreV1BackupPath(dataFile), 'utf-8')).toBe(original) + }) + + it('fails without leaving a partial backup when the data file is unreadable', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-')) + tempDirs.push(dir) + const missing = join(dir, 'missing.json') + const result = createPinnedPreV1Backup(missing, '{}') + expect(result.ok).toBe(false) + expect(existsSync(pinnedPreV1BackupPath(missing))).toBe(false) + expect(existsSync(`${pinnedPreV1BackupPath(missing)}.tmp`)).toBe(false) + }) +}) + +describe('migrateAgentCatalogSchema', () => { + it('maps shipped legacy null (and missing) defaults to auto exactly once', () => { + for (const legacyDefault of [null, undefined]) { + const outcome = migrateAgentCatalogSchema({ + settings: legacyDefault === undefined ? {} : { defaultTuiAgent: legacyDefault }, + preV1RawContents: '{}', + createBackup: () => ({ ok: true, created: true }) + }) + expect(outcome.didMigrate).toBe(true) + expect(outcome.settingsPatch.defaultTuiAgent).toBe('auto') + expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBe(1) + expect(outcome.settingsPatch.agentCatalogRevision).toBe(1) + expect(outcome.settingsPatch.agentReferenceRevision).toBe(1) + } + }) + + it('preserves explicit blank and concrete-id defaults', () => { + for (const explicit of ['blank', 'codex'] as const) { + const outcome = migrateAgentCatalogSchema({ + settings: { defaultTuiAgent: explicit }, + preV1RawContents: '{}', + createBackup: () => ({ ok: true, created: true }) + }) + expect(outcome.didMigrate).toBe(true) + expect('defaultTuiAgent' in outcome.settingsPatch).toBe(false) + } + }) + + it('is idempotent: a second load with v1 stamped is a no-op', () => { + const outcome = migrateAgentCatalogSchema({ + settings: { + agentCatalogSchemaVersion: 1, + agentCatalogRevision: 7, + agentReferenceRevision: 3, + defaultTuiAgent: null + }, + preV1RawContents: '{}', + createBackup: () => { + throw new Error('backup must not run for a v1 profile') + } + }) + expect(outcome.didMigrate).toBe(false) + expect(outcome.settingsPatch).toEqual({}) + // Post-v1 null stays null: repair-needed defaults never become Auto again. + }) + + it('performs no v1 write when backup creation fails and forces pre-v1 shape', () => { + const outcome = migrateAgentCatalogSchema({ + settings: { defaultTuiAgent: null }, + preV1RawContents: '{"settings":{"defaultTuiAgent":null}}', + createBackup: () => ({ ok: false, error: 'disk full' }) + }) + expect(outcome.didMigrate).toBe(false) + expect(outcome.backupError).toBe('disk full') + expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBeUndefined() + expect(outcome.settingsPatch.agentCatalogRevision).toBeUndefined() + expect(outcome.settingsPatch.agentReferenceRevision).toBeUndefined() + expect(outcome.settingsPatch.defaultTuiAgent).toBeNull() + // The forced patch must explicitly carry the pre-v1 keys so fresh-install + // defaults cannot leak through the settings spread. + expect('agentCatalogSchemaVersion' in outcome.settingsPatch).toBe(true) + expect('customTuiAgents' in outcome.settingsPatch).toBe(true) + expect('deletedCustomTuiAgents' in outcome.settingsPatch).toBe(true) + }) + + it('skips the backup for a fresh install with no persisted file', () => { + const outcome = migrateAgentCatalogSchema({ + settings: undefined, + preV1RawContents: null, + createBackup: () => { + throw new Error('backup must not run for a fresh install') + } + }) + expect(outcome.didMigrate).toBe(true) + expect(outcome.settingsPatch.defaultTuiAgent).toBe('auto') + expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBe(1) + }) + + it('normalizes hand-edited negative or non-integer revisions on v1 profiles', () => { + const outcome = migrateAgentCatalogSchema({ + settings: { + agentCatalogSchemaVersion: 1, + agentCatalogRevision: -5 as number, + agentReferenceRevision: 1.5 as number + }, + preV1RawContents: '{}', + createBackup: () => ({ ok: true, created: true }) + }) + expect(outcome.didMigrate).toBe(true) + expect(outcome.settingsPatch.agentCatalogRevision).toBe(1) + expect(outcome.settingsPatch.agentReferenceRevision).toBe(1) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-schema-migration.ts b/src/main/agent-launch/agent-catalog-schema-migration.ts new file mode 100644 index 00000000000..60b1530394e --- /dev/null +++ b/src/main/agent-launch/agent-catalog-schema-migration.ts @@ -0,0 +1,136 @@ +// One-time agent-catalog v1 schema migration: maps the shipped legacy +// `defaultTuiAgent: null` (which meant Auto) to the explicit persisted 'auto' +// and stamps `agentCatalogSchemaVersion: 1`. Before the first v1 write of an +// existing profile, a pinned same-permission pre-v1 backup is created beside +// the rotating backups; if that backup cannot be created, no v1 write happens +// and launch behavior stays on the clean built-in baseline. + +import { + closeSync, + existsSync, + fsyncSync, + openSync, + renameSync, + statSync, + unlinkSync, + writeSync +} from 'node:fs' +import type { GlobalSettings } from '../../shared/types' + +export const AGENT_CATALOG_SCHEMA_VERSION = 1 + +export function pinnedPreV1BackupPath(dataFile: string): string { + return `${dataFile}.pre-agent-catalog-v1.backup` +} + +export type PinnedBackupResult = { ok: true; created: boolean } | { ok: false; error: string } + +/** Write the exact pre-v1 raw bytes to the pinned backup with the data file's + * permissions, fsync, then atomically rename into place. An existing pinned + * backup is kept (a crash between backup and first v1 write must not let a + * second attempt overwrite the original pre-v1 state). */ +export function createPinnedPreV1Backup(dataFile: string, rawContents: string): PinnedBackupResult { + const backupFile = pinnedPreV1BackupPath(dataFile) + try { + if (existsSync(backupFile)) { + return { ok: true, created: false } + } + const mode = statSync(dataFile).mode & 0o777 + const tmpFile = `${backupFile}.tmp` + const fd = openSync(tmpFile, 'w', mode) + try { + writeSync(fd, rawContents) + fsyncSync(fd) + } finally { + closeSync(fd) + } + try { + renameSync(tmpFile, backupFile) + } catch (error) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort tmp cleanup; the rename failure is the reported error. + } + throw error + } + return { ok: true, created: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} + +export type AgentCatalogSchemaMigrationOutcome = { + /** Patch merged into loaded settings; empty object when nothing changed. */ + settingsPatch: Partial + didMigrate: boolean + /** Present when the pinned backup failed; the profile stays pre-v1 and Settings + * must surface a local migration error. */ + backupError?: string +} + +function normalizeRevision(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback +} + +/** Compute the one-time v1 migration for loaded settings. Pure except for the + * injected backup step; a second load with v1 already stamped is a no-op. */ +export function migrateAgentCatalogSchema(args: { + settings: Partial | undefined + /** Null for a fresh install with no persisted file (no pre-v1 state to pin). */ + preV1RawContents: string | null + createBackup: () => PinnedBackupResult +}): AgentCatalogSchemaMigrationOutcome { + const settings = args.settings + const currentVersion = normalizeRevision(settings?.agentCatalogSchemaVersion, 0) + if (currentVersion >= AGENT_CATALOG_SCHEMA_VERSION) { + // Revisions must remain monotonic non-negative integers even if hand-edited. + const catalogRevision = normalizeRevision(settings?.agentCatalogRevision, 1) + const referenceRevision = normalizeRevision(settings?.agentReferenceRevision, 1) + const patch: Partial = {} + let didMigrate = false + if (settings?.agentCatalogRevision !== catalogRevision) { + patch.agentCatalogRevision = catalogRevision + didMigrate = true + } + if (settings?.agentReferenceRevision !== referenceRevision) { + patch.agentReferenceRevision = referenceRevision + didMigrate = true + } + return { settingsPatch: patch, didMigrate } + } + + if (args.preV1RawContents !== null) { + const backup = args.createBackup() + if (!backup.ok) { + // No v1 write of any kind: force the merged settings back to the exact + // pre-v1 shape so the fresh-install defaults (schema version, 'auto', + // empty catalog arrays) cannot leak through the defaults spread. + return { + settingsPatch: { + agentCatalogSchemaVersion: undefined, + agentCatalogRevision: undefined, + agentReferenceRevision: undefined, + customTuiAgents: settings?.customTuiAgents, + deletedCustomTuiAgents: settings?.deletedCustomTuiAgents, + defaultTuiAgent: settings?.defaultTuiAgent ?? null + }, + didMigrate: false, + backupError: backup.error + } + } + } + + const patch: Partial = { + agentCatalogSchemaVersion: AGENT_CATALOG_SCHEMA_VERSION, + agentCatalogRevision: 1, + agentReferenceRevision: 1 + } + // Shipped legacy null meant Auto. This mapping runs exactly once, before any + // repair can produce a new null; later repair-generated null stays null. + const rawDefault = settings?.defaultTuiAgent + if (rawDefault === null || rawDefault === undefined) { + patch.defaultTuiAgent = 'auto' + } + return { settingsPatch: patch, didMigrate: true } +} diff --git a/src/main/agent-launch/agent-catalog-service.test.ts b/src/main/agent-launch/agent-catalog-service.test.ts new file mode 100644 index 00000000000..ec4834418a9 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-service.test.ts @@ -0,0 +1,560 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + CustomTuiAgent, + CustomTuiAgentId, + GlobalSettings, + Repo, + TerminalAgentQuickCommand, + TuiAgent, + WorktreeMeta +} from '../../shared/types' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { Store } from '../persistence' +import { AgentCatalogService } from './agent-catalog-service' +import { getHostAgentSessionRecordStore } from './agent-session-record-store-host' +import type { HostSessionLaunchRecord } from './agent-session-record-store' +import { getHostBackgroundAgentLaunchStore } from './background-agent-launch-store-host' + +const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' +const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' + +function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +function liveAgent(overrides: Partial = {}): CustomTuiAgent { + return { + id: customId('codex'), + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +type StoreStubState = { + settings: GlobalSettings + repos: Repo[] + automations: Automation[] + automationRuns?: AutomationRun[] + worktreeMeta?: Record + failAutomationScan?: boolean + failWorktreeScan?: boolean +} + +function makeStoreStub(state: StoreStubState): Store { + const stub = { + getSettings: () => state.settings, + updateSettings: (updates: Partial) => { + state.settings = { ...state.settings, ...updates } + return state.settings + }, + getRepos: () => state.repos, + listAutomations: () => { + if (state.failAutomationScan) { + throw new Error('store unavailable') + } + return state.automations + }, + listAutomationRuns: () => state.automationRuns ?? [], + getAllWorktreeMeta: () => { + if (state.failWorktreeScan) { + throw new Error('store unavailable') + } + return state.worktreeMeta ?? {} + } + } + return stub as unknown as Store +} + +function baseSettings(overrides: Partial = {}): GlobalSettings { + return { + defaultTuiAgent: 'auto', + disabledTuiAgents: [], + customTuiAgents: [], + deletedCustomTuiAgents: [], + agentCatalogRevision: 1, + agentReferenceRevision: 1, + terminalQuickCommands: [], + agentCmdOverrides: {}, + ...overrides + } as GlobalSettings +} + +function tombstoneFor(id: CustomTuiAgentId) { + return { id, baseAgent: 'codex' as const, label: 'Gone', deletedAt: 1 } +} + +function agentQuickCommand(agent: CustomTuiAgentId): TerminalAgentQuickCommand { + return { id: 'qc-1', label: 'Q', action: 'agent-prompt', agent, prompt: 'p' } +} + +describe('tombstone reference GC across owners', () => { + const deadId = customId('codex', UUID_B) + + function serviceWith(state: Partial): { + service: AgentCatalogService + state: StoreStubState + } { + const fullState: StoreStubState = { + settings: baseSettings(), + repos: [], + automations: [], + ...state + } + return { service: new AgentCatalogService(makeStoreStub(fullState)), state: fullState } + } + + it('retains the tombstone while the default references it and prunes after the last reference clears', () => { + const { service, state } = serviceWith({ + settings: baseSettings({ + defaultTuiAgent: deadId, + deletedCustomTuiAgents: [tombstoneFor(deadId)] + }) + }) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1) + + // Create with prune: tombstone retained because the default still points at it. + const created = service.mutate({ + expectedRevision: 1, + mutation: { + kind: 'create', + baseAgent: 'claude', + draft: { label: 'Other', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(created.ok).toBe(true) + expect(state.settings.deletedCustomTuiAgents).toHaveLength(1) + + // Clear the default (last reference), then the next prune removes it. + const cleared = service.mutate({ + expectedRevision: state.settings.agentCatalogRevision ?? 1, + mutation: { kind: 'set-default', agent: 'auto' } + }) + expect(cleared.ok).toBe(true) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0) + const created2 = service.mutate({ + expectedRevision: state.settings.agentCatalogRevision ?? 1, + mutation: { + kind: 'create', + baseAgent: 'gemini', + draft: { label: 'Another', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(created2.ok).toBe(true) + expect(state.settings.deletedCustomTuiAgents).toHaveLength(0) + }) + + it('counts quick-command, commit-message, source-control (global and repo), and automation references', () => { + const { service } = serviceWith({ + settings: baseSettings({ + terminalQuickCommands: [agentQuickCommand(deadId)], + commitMessageAi: { + enabled: true, + agentId: deadId, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + }, + sourceControlAi: { + enabled: true, + agentId: deadId, + actions: { 'commit-message': { agentId: deadId, commandInputTemplate: '' } }, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand: '', + instructionsByOperation: {} + } as GlobalSettings['sourceControlAi'], + deletedCustomTuiAgents: [tombstoneFor(deadId)] + }), + repos: [ + { + id: 'repo-1', + sourceControlAi: { + actionOverrides: { 'pr-review': { agentId: deadId, commandInputTemplate: '' } } + } + } as unknown as Repo + ], + automations: [{ id: 'auto-1', agentId: deadId } as unknown as Automation] + }) + // quick-command 1 + commit-message agentId 1 + sourceControlAi agentId 1 + + // action recipe 1 + repo override 1 + automation 1 = 6 + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(6) + const summary = service.getReferenceSummaries(deadId) + expect(summary).toContainEqual({ owner: 'quick-command', count: 1 }) + expect(summary).toContainEqual({ owner: 'commit-message', count: 2 }) + expect(summary).toContainEqual({ owner: 'source-control-recipe', count: 2 }) + expect(summary).toContainEqual({ owner: 'automation', count: 1 }) + }) + + it('retains via a run launch-failure even after the definition agent changed, and prunes after the run clears', () => { + // The automation definition points at a live agent now, but a past run's + // structured launch failure still references the deleted custom id — the + // tombstone must stay retained until that run record is gone too. + const { service, state } = serviceWith({ + settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }), + automations: [{ id: 'auto-1', agentId: 'claude' } as unknown as Automation], + automationRuns: [ + { + id: 'run-1', + agentLaunchFailure: { + version: 1, + code: 'base_agent_disabled', + requestedAgent: deadId, + failureId: 'rf-1', + intent: 'automation', + occurredAt: 1 + } + } as unknown as AutomationRun + ] + }) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1) + expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'automation', count: 1 }) + + state.automationRuns = [] + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0) + }) + + it('counts workspace pending-launch and durable-failure references and prunes after the last clears', () => { + const { service, state } = serviceWith({ + settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }), + worktreeMeta: { + 'wt-1': { + pendingAgentLaunch: { operationId: 'op-1', requestedAgent: deadId } + } as unknown as WorktreeMeta, + 'wt-2': { + agentLaunchFailure: { + version: 1, + code: 'spawn_failed', + requestedAgent: deadId, + failureId: 'f-1', + intent: 'interactive', + occurredAt: 1 + } + } as unknown as WorktreeMeta + } + }) + // pendingAgentLaunch.requestedAgent + agentLaunchFailure.requestedAgent = 2. + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(2) + expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'workspace', count: 2 }) + + // Last reference cleared -> the tombstone can prune. + state.worktreeMeta = {} + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0) + }) + + it('retains the tombstone when the workspace store is unavailable', () => { + const { service } = serviceWith({ + settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }), + failWorktreeScan: true + }) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown') + }) + + it('treats an unavailable owner store as unknown and retains the tombstone', () => { + const { service, state } = serviceWith({ + settings: baseSettings({ + deletedCustomTuiAgents: [tombstoneFor(deadId)] + }), + failAutomationScan: true + }) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown') + const created = service.mutate({ + expectedRevision: 1, + mutation: { + kind: 'create', + baseAgent: 'claude', + draft: { label: 'New One', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(created.ok).toBe(true) + expect(state.settings.deletedCustomTuiAgents).toHaveLength(1) + }) + + it('reference removal prunes the tombstone and advances both revisions', () => { + const { service, state } = serviceWith({ + settings: baseSettings({ + terminalQuickCommands: [agentQuickCommand(deadId)], + deletedCustomTuiAgents: [tombstoneFor(deadId)] + }) + }) + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { kind: 'quick-command-delete', id: 'qc-1' } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.referenceRevision).toBe(2) + // Tombstone pruned in the follow-up catalog write with its own revision bump. + expect(state.settings.deletedCustomTuiAgents).toHaveLength(0) + expect(state.settings.agentCatalogRevision).toBe(2) + expect(result.catalogRevision).toBe(2) + }) +}) + +describe('session owner (host-private resume records)', () => { + const recordStore = getHostAgentSessionRecordStore() + const deadId = customId('codex', UUID_B) + + afterEach(() => { + // The record store is a host-wide singleton; clear seeded records between tests. + recordStore.rebuildRecordsFrom([]) + vi.restoreAllMocks() + }) + + function sessionRecord(requestedAgent: TuiAgent): HostSessionLaunchRecord { + return { + worktreeId: 'wt-session', + requestedAgent, + baseAgent: 'codex', + providerSession: { key: 'session_id', id: 'sess-1' }, + registeredAt: 1, + updatedAt: 1 + } + } + + function serviceWithTombstone(): AgentCatalogService { + const state: StoreStubState = { + settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }), + repos: [], + automations: [] + } + return new AgentCatalogService(makeStoreStub(state)) + } + + it('retains the tombstone while a resumable session references the custom id and prunes after it is forgotten', () => { + const service = serviceWithTombstone() + + recordStore.rebuildRecordsFrom([sessionRecord(deadId)]) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1) + expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'session', count: 1 }) + + // Forgetting the last referencing session clears the reference so it can prune. + recordStore.rebuildRecordsFrom([]) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0) + }) + + it('retains the tombstone when the session record store cannot be read', () => { + const service = serviceWithTombstone() + vi.spyOn(recordStore, 'referencedRequestedAgents').mockImplementation(() => { + throw new Error('store unavailable') + }) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown') + }) +}) + +describe('background owner (host-private generic launch attempts)', () => { + const attemptStore = getHostBackgroundAgentLaunchStore() + const deadId = customId('codex', UUID_B) + + afterEach(() => { + // Host-wide singleton; clear seeded attempts between tests. + attemptStore.rebuildFrom([]) + vi.restoreAllMocks() + }) + + function serviceWithTombstone(): AgentCatalogService { + const state: StoreStubState = { + settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }), + repos: [], + automations: [] + } + return new AgentCatalogService(makeStoreStub(state)) + } + + it('retains the tombstone while a background attempt references the custom id and prunes after it is gone', () => { + const service = serviceWithTombstone() + + attemptStore.create({ + attemptId: 'attempt-dead', + worktreeId: 'wt-bg', + operationId: 'op-1', + requestedAgent: deadId, + baseAgent: 'codex' + }) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1) + expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'background', count: 1 }) + + // Pruning the last referencing attempt clears the reference. + attemptStore.rebuildFrom([]) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0) + }) + + it('retains the tombstone when the background attempt store cannot be read', () => { + const service = serviceWithTombstone() + vi.spyOn(attemptStore, 'referencedRequestedAgents').mockImplementation(() => { + throw new Error('store unavailable') + }) + expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown') + }) +}) + +describe('base-disable impact (§973)', () => { + const recordStore = getHostAgentSessionRecordStore() + const derivId = customId('claude', UUID_A) + const otherBaseId = customId('codex', UUID_B) + + afterEach(() => { + recordStore.rebuildRecordsFrom([]) + vi.restoreAllMocks() + }) + + function sessionRecord( + baseAgent: 'claude' | 'codex', + requestedAgent: TuiAgent, + sessionId: string + ): HostSessionLaunchRecord { + return { + worktreeId: 'wt-impact', + requestedAgent, + baseAgent, + // Both claude and codex key on 'session_id' (only antigravity differs); the + // key value is irrelevant here — countRecordsByBase reads baseAgent only. + providerSession: { key: 'session_id', id: sessionId }, + registeredAt: 1, + updatedAt: 1 + } + } + + function impactService(state: Partial = {}): AgentCatalogService { + const fullState: StoreStubState = { + settings: baseSettings({ + defaultTuiAgent: 'claude', + customTuiAgents: [ + liveAgent({ id: derivId, baseAgent: 'claude', label: 'Claude Deriv' }), + liveAgent({ id: otherBaseId, baseAgent: 'codex', label: 'Codex Custom' }) + ], + terminalQuickCommands: [ + agentQuickCommand(derivId), + { id: 'qc-2', label: 'Q2', action: 'agent-prompt', agent: 'codex', prompt: 'p' } + ] + }), + repos: [], + automations: [], + ...state + } + return new AgentCatalogService(makeStoreStub(fullState)) + } + + it('counts base-direct + derivative saved references (excluding sessions) and resumable sessions by base', () => { + const service = impactService() + // A claude session on the derivative is counted as a session, NOT double-counted + // under savedReferences; a codex session on a different base is ignored for claude. + recordStore.rebuildRecordsFrom([ + sessionRecord('claude', derivId, 'sess-claude'), + sessionRecord('codex', otherBaseId, 'sess-codex') + ]) + const impact = service.getBaseDisableImpact('claude') + // default 'claude' (base-direct) + quick-command on the derivative = 2. + expect(impact.savedReferences).toEqual({ count: 2, atLeast: false }) + expect(impact.resumableSessions).toEqual({ count: 1, atLeast: false }) + }) + + it('reports atLeast on saved references when a reference owner store cannot be read', () => { + const service = impactService({ failAutomationScan: true }) + const impact = service.getBaseDisableImpact('claude') + // Readable owners (default + quick-command) still count; automation is unknown. + expect(impact.savedReferences).toEqual({ count: 2, atLeast: true }) + expect(impact.resumableSessions.atLeast).toBe(false) + }) + + it('reports atLeast on resumable sessions when the record store cannot be read', () => { + const service = impactService() + vi.spyOn(recordStore, 'countRecordsByBase').mockImplementation(() => { + throw new Error('store unavailable') + }) + const impact = service.getBaseDisableImpact('claude') + expect(impact.resumableSessions).toEqual({ count: 0, atLeast: true }) + expect(impact.savedReferences.atLeast).toBe(false) + }) + + it('returns zero impact for a base with no references or sessions', () => { + const service = impactService() + const impact = service.getBaseDisableImpact('gemini') + expect(impact.savedReferences).toEqual({ count: 0, atLeast: false }) + expect(impact.resumableSessions).toEqual({ count: 0, atLeast: false }) + }) +}) + +describe('delete -> tombstone -> reference lifecycle', () => { + it('keeps the tombstone alive through delete while a quick command references it', () => { + const live = liveAgent() + const state: StoreStubState = { + settings: baseSettings({ + customTuiAgents: [live], + terminalQuickCommands: [agentQuickCommand(live.id)] + }), + repos: [], + automations: [] + } + const service = new AgentCatalogService(makeStoreStub(state)) + const deleted = service.mutate({ + expectedRevision: 1, + mutation: { kind: 'delete-custom', id: live.id } + }) + expect(deleted.ok).toBe(true) + expect(state.settings.customTuiAgents).toHaveLength(0) + expect(state.settings.deletedCustomTuiAgents?.[0]?.id).toBe(live.id) + expect(service.tombstoneReferenceIndex.countReferences(live.id)).toBe(1) + // The label stays reserved while referenced. + const relabel = service.mutate({ + expectedRevision: state.settings.agentCatalogRevision ?? 1, + mutation: { + kind: 'create', + baseAgent: 'codex', + draft: { label: 'My Codex', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(relabel).toMatchObject({ ok: false, code: 'duplicate_agent_label' }) + }) +}) + +describe('local draft endpoint', () => { + it('returns exactly one row at the current revision and rejects stale locators', () => { + const live = liveAgent({ env: { SECRET: 'value' } }) + const state: StoreStubState = { + settings: baseSettings({ customTuiAgents: [live], agentCatalogRevision: 7 }), + repos: [], + automations: [] + } + const service = new AgentCatalogService(makeStoreStub(state)) + const draft = service.getLocalDraft({ id: live.id }, 7) + expect(draft).toMatchObject({ + status: 'ready', + revision: 7, + draft: { label: 'My Codex', env: { SECRET: 'value' } } + }) + expect(service.getLocalDraft({ id: live.id }, 6)).toEqual({ status: 'stale' }) + expect(service.getLocalDraft({ id: customId('claude', UUID_B) }, 7)).toEqual({ + status: 'stale' + }) + }) + + it('never returns env values in the list snapshot while the draft carries them', () => { + const live = liveAgent({ env: { SECRET: 'value' } }) + const state: StoreStubState = { + settings: baseSettings({ customTuiAgents: [live] }), + repos: [], + automations: [] + } + const service = new AgentCatalogService(makeStoreStub(state)) + const snapshotText = JSON.stringify(service.getLocalSnapshot()) + expect(snapshotText).not.toContain('SECRET') + expect(snapshotText).not.toContain('value') + const remoteText = JSON.stringify(service.getRemoteSnapshot()) + expect(remoteText).not.toContain('SECRET') + expect(remoteText).not.toContain('value') + // Env presence is summarized numerically only. + const local = service.getLocalSnapshot() + const row = local.customAgents[0] + expect(row.status).toBe('ready') + if (row.status === 'ready') { + expect(row.envSummary.entryCount).toBe(1) + } + }) +}) diff --git a/src/main/agent-launch/agent-catalog-service.ts b/src/main/agent-launch/agent-catalog-service.ts new file mode 100644 index 00000000000..14c4c8b50c6 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-service.ts @@ -0,0 +1,354 @@ +// Main-owned agent-catalog service: the single authoring authority. Desktop +// Settings mutate through it (never by writing whole settings arrays), it owns +// repair tokens and the tombstone reference index, and it enforces the local +// (16 MiB) and remote-projection (512 KiB) payload budgets before any write. + +import type { Store } from '../persistence' +import type { BuiltInTuiAgent, CustomTuiAgentId, GlobalSettings } from '../../shared/types' +import type { + AgentCatalogMutationRequest, + AgentCatalogMutationResult, + LocalAgentCatalogSnapshot, + LocalCustomAgentDraftResult +} from '../../shared/agent-catalog-snapshot' +import { MAX_LOCAL_AGENT_DRAFT_BYTES } from '../../shared/agent-catalog-snapshot' +import { utf8ByteLength } from '../../shared/custom-tui-agents' +import { + AgentCatalogRepairTokenRegistry, + applyAgentCatalogMutation +} from './agent-catalog-mutations' +import { + buildAgentCatalogSnapshot, + buildLocalAgentCatalogSnapshot, + measureAgentCatalogProjection, + measureLocalAgentCatalogStorage, + normalizeCatalogFromSettings +} from './agent-catalog-projections' +import { + AgentTombstoneReferenceIndex, + type AgentReferenceSummary +} from './agent-tombstone-reference-index' +import { registerBuiltInOwnerScanners } from './agent-catalog-owner-scanners' +import { getHostAgentSessionRecordStore } from './agent-session-record-store-host' +import { applyAgentReferenceMutation } from './agent-reference-mutations' +import type { + AgentReferenceMutationRequest, + AgentReferenceMutationResult, + AgentReferenceProjectionError, + AgentReferenceSnapshot, + BaseDisableImpact, + LocalAgentReferenceSnapshot +} from '../../shared/agent-reference-snapshot' + +/** Mutations that reduce risk/size and stay allowed while a payload budget is + * already exceeded; they must never add arbitrary user text or a reference. */ +function isSecurityReducingMutation(request: AgentCatalogMutationRequest): boolean { + const mutation = request.mutation + switch (mutation.kind) { + case 'delete-custom': + return true + case 'set-enabled': + return mutation.enabled === false + case 'set-default': + return mutation.agent === 'auto' || mutation.agent === 'blank' + case 'repair-corrupt': + return mutation.action.kind === 'discard' + case 'resolve-duplicate-id': + return mutation.rows.every((row) => row.action.kind === 'discard') + case 'create': + case 'duplicate': + case 'update-custom': + case 'update-built-in': + return false + } +} + +let serviceInstance: AgentCatalogService | null = null +let serviceStore: Store | null = null + +/** One service per Store instance (profile switching replaces the Store). Both + * local IPC and the runtime RPC layer must share this instance so repair + * tokens and reference scanners agree. */ +export function getOrCreateAgentCatalogService(store: Store): AgentCatalogService { + if (!serviceInstance || serviceStore !== store) { + serviceInstance = new AgentCatalogService(store) + serviceStore = store + } + return serviceInstance +} + +export class AgentCatalogService { + private readonly repairTokens = new AgentCatalogRepairTokenRegistry() + private readonly referenceIndex = new AgentTombstoneReferenceIndex() + private readonly changeListeners = new Set<(revision: number) => void>() + + constructor(private readonly store: Store) { + registerBuiltInOwnerScanners(this.referenceIndex, this.store) + } + + /** Later units (worktree pending launches, background attempts, orchestration, + * sleeping sessions) register their owner scanners through this. */ + get tombstoneReferenceIndex(): AgentTombstoneReferenceIndex { + return this.referenceIndex + } + + onDidChange(listener: (revision: number) => void): () => void { + this.changeListeners.add(listener) + return () => { + this.changeListeners.delete(listener) + } + } + + getRevision(): number { + return this.store.getSettings().agentCatalogRevision ?? 1 + } + + getLocalSnapshot(): LocalAgentCatalogSnapshot { + return buildLocalAgentCatalogSnapshot(this.store.getSettings(), this.repairTokens) + } + + getRemoteSnapshot(): ReturnType { + return buildAgentCatalogSnapshot(this.store.getSettings()) + } + + /** Local-desktop-only reference summary for delete confirmation and "Review + * references"; owner kind + count only, no prompt/config/env. */ + getReferenceSummaries(id: CustomTuiAgentId): AgentReferenceSummary[] { + return this.referenceIndex.summarizeReferences(id) + } + + /** §973 base-disable impact: the counts of persisted-owner references and + * resumable sessions that will block when a built-in base is disabled. Saved + * references include the base id and any live custom derivative of it (a + * derivative can't launch without its harness); sessions are counted by base + * and excluded from the reference scan so the two counts never overlap. Counts + * only — never a label or config. Enabled-derivative counts stay client-side. */ + getBaseDisableImpact(base: BuiltInTuiAgent): BaseDisableImpact { + const catalog = normalizeCatalogFromSettings(this.store.getSettings()) + const derivativeIds = new Set() + for (const agent of catalog.liveCustomAgents) { + if (agent.baseAgent === base) { + derivativeIds.add(agent.id) + } + } + const matches = (value: unknown): boolean => + value === base || (typeof value === 'string' && derivativeIds.has(value)) + const saved = this.referenceIndex.countMatchingReferences(matches, { + excludeOwners: new Set(['session']) + }) + let resumableSessions: BaseDisableImpact['resumableSessions'] + try { + resumableSessions = { + count: getHostAgentSessionRecordStore().countRecordsByBase(base), + atLeast: false + } + } catch { + resumableSessions = { count: 0, atLeast: true } + } + return { + savedReferences: { count: saved.count, atLeast: !saved.complete }, + resumableSessions + } + } + + /** Single-record full-env editor read, access-checked by the preload boundary + * and capped at 1 MiB. Never registered as a runtime RPC. */ + getLocalDraft( + locator: { id: CustomTuiAgentId } | { repairToken: string }, + expectedRevision: number + ): LocalCustomAgentDraftResult | { status: 'stale' } { + const settings = this.store.getSettings() + const revision = settings.agentCatalogRevision ?? 1 + if (expectedRevision !== revision) { + return { status: 'stale' } + } + const catalog = normalizeCatalogFromSettings(settings) + const raw: unknown = + 'id' in locator + ? (catalog.liveById.get(locator.id) ?? + catalog.repairRequiredById.get(locator.id)?.raw ?? + null) + : this.repairTokens.resolve(locator.repairToken, [ + ...catalog.corruptRows, + ...catalog.repairRequiredById.values() + ])?.raw + if (raw === null || raw === undefined) { + return { status: 'stale' } + } + const bytes = utf8ByteLength(JSON.stringify(raw) ?? 'null') + if (bytes > MAX_LOCAL_AGENT_DRAFT_BYTES) { + return { status: 'too-large', revision, bytes, maxBytes: MAX_LOCAL_AGENT_DRAFT_BYTES } + } + const record = raw as Record + return { + status: 'ready', + revision, + draft: { + label: typeof record.label === 'string' ? record.label : '', + commandOverride: typeof record.commandOverride === 'string' ? record.commandOverride : null, + args: typeof record.args === 'string' ? record.args : '', + env: + record.env && typeof record.env === 'object' && !Array.isArray(record.env) + ? ({ ...(record.env as Record) } as Record) + : {}, + syncEnv: record.syncEnv === true + } + } + } + + getReferenceRevision(): number { + return this.store.getSettings().agentReferenceRevision ?? 1 + } + + private buildReferenceSnapshot(): AgentReferenceSnapshot { + const settings = this.store.getSettings() + return { + version: 1, + revision: settings.agentReferenceRevision ?? 1, + terminalQuickCommands: settings.terminalQuickCommands ?? [], + ...(settings.commitMessageAi ? { commitMessageAi: settings.commitMessageAi } : {}), + ...(settings.sourceControlAi ? { sourceControlAi: settings.sourceControlAi } : {}) + } + } + + private measureReferenceProjection(): { bytes: number; tooLarge: boolean } { + const bytes = utf8ByteLength(JSON.stringify(this.buildReferenceSnapshot())) + return { bytes, tooLarge: bytes > 524_288 } + } + + /** Remote (runtime RPC) reference snapshot; typed projection error when over + * the 512 KiB frame budget. */ + getRemoteReferenceSnapshot(): AgentReferenceSnapshot | AgentReferenceProjectionError { + const snapshot = this.buildReferenceSnapshot() + const { tooLarge } = this.measureReferenceProjection() + if (tooLarge) { + return { + version: 1, + revision: snapshot.revision, + code: 'agent_reference_payload_too_large', + maxBytes: 524_288 + } + } + return snapshot + } + + /** Uncapped authoring/repair view over local preload IPC only. */ + getLocalReferenceSnapshot(): LocalAgentReferenceSnapshot { + const snapshot = this.buildReferenceSnapshot() + const { bytes, tooLarge } = this.measureReferenceProjection() + return { + ...snapshot, + projection: tooLarge + ? { status: 'too-large', bytes, maxBytes: 524_288 } + : { status: 'ready', bytes, maxBytes: 524_288 } + } + } + + mutateReferences( + request: AgentReferenceMutationRequest + ): AgentReferenceMutationResult { + const settings = this.store.getSettings() + const currentReferenceRevision = settings.agentReferenceRevision ?? 1 + const application = applyAgentReferenceMutation({ + settings, + request, + currentReferenceRevision, + catalog: normalizeCatalogFromSettings(settings) + }) + if (!application.ok) { + return { + ok: false, + code: application.code, + referenceRevision: currentReferenceRevision, + catalogRevision: this.getRevision(), + ...(application.code === 'reference_revision_conflict' + ? { snapshot: this.getLocalReferenceSnapshot() } + : {}), + ...(application.owner ? { owner: application.owner } : {}), + ...(application.field ? { field: application.field } : {}), + ...(application.reason ? { reason: application.reason } : {}) + } + } + // Owner change commits before any prune; a failure between the two leaves + // the tombstone conservatively retained for the next indexed recheck. + this.store.updateSettings(application.patch, { notifyListeners: true }) + this.pruneUnreferencedTombstonesAfterReferenceRemoval() + return { + ok: true, + referenceRevision: application.newReferenceRevision, + catalogRevision: this.getRevision(), + snapshot: this.getLocalReferenceSnapshot() + } + } + + /** Reference-aware prune run after a reference removal; a prune advances and + * publishes the catalog revision so receivers replace their snapshot. */ + private pruneUnreferencedTombstonesAfterReferenceRemoval(): void { + const settings = this.store.getSettings() + const tombstones = settings.deletedCustomTuiAgents ?? [] + if (tombstones.length === 0) { + return + } + const retained = tombstones.filter( + (tombstone) => this.referenceIndex.countReferences(tombstone.id) !== 0 + ) + if (retained.length === tombstones.length) { + return + } + const newRevision = (settings.agentCatalogRevision ?? 1) + 1 + this.store.updateSettings( + { deletedCustomTuiAgents: retained, agentCatalogRevision: newRevision }, + { notifyListeners: true } + ) + for (const listener of this.changeListeners) { + listener(newRevision) + } + } + + mutate(request: AgentCatalogMutationRequest): AgentCatalogMutationResult { + const settings = this.store.getSettings() + const currentRevision = settings.agentCatalogRevision ?? 1 + const application = applyAgentCatalogMutation({ + settings, + request, + currentRevision, + repairTokens: this.repairTokens, + countTombstoneReferences: (id) => this.referenceIndex.countReferences(id) + }) + if (!application.ok) { + const revisionForError = + application.code === 'catalog_revision_conflict' ? currentRevision : currentRevision + return { + ok: false, + code: application.code, + revision: revisionForError, + ...(application.code === 'catalog_revision_conflict' + ? { snapshot: this.getLocalSnapshot() } + : {}), + ...(application.field ? { field: application.field } : {}), + ...(application.reason ? { reason: application.reason } : {}), + ...(application.envEntryIndex !== undefined + ? { envEntryIndex: application.envEntryIndex } + : {}) + } + } + + // Payload budgets are checked on the post-mutation state; while a budget is + // exceeded only the security-reducing allowlist may still commit. + const nextSettings = { ...settings, ...application.patch } + const localStorageStatus = measureLocalAgentCatalogStorage(nextSettings as GlobalSettings) + const projectionStatus = measureAgentCatalogProjection(nextSettings as GlobalSettings) + if (localStorageStatus.status === 'too-large' && !isSecurityReducingMutation(request)) { + return { ok: false, code: 'agent_catalog_local_payload_too_large', revision: currentRevision } + } + if (projectionStatus.status === 'too-large' && !isSecurityReducingMutation(request)) { + return { ok: false, code: 'agent_catalog_payload_too_large', revision: currentRevision } + } + + this.store.updateSettings(application.patch, { notifyListeners: true }) + for (const listener of this.changeListeners) { + listener(application.newRevision) + } + return { ok: true, revision: application.newRevision, snapshot: this.getLocalSnapshot() } + } +} diff --git a/src/main/agent-launch/agent-launch-admission-store.test.ts b/src/main/agent-launch/agent-launch-admission-store.test.ts new file mode 100644 index 00000000000..72b3aaac63d --- /dev/null +++ b/src/main/agent-launch/agent-launch-admission-store.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator, + MAX_PENDING_LAUNCHES_PER_HOST, + MAX_PENDING_LAUNCHES_PER_PRINCIPAL, + MAX_PENDING_LAUNCHES_PER_WORKTREE, + MAX_PENDING_LAUNCHES_REMOTE_TOTAL, + type AdmissionPrincipal +} from './agent-launch-admission-store' + +const SNAPSHOT: AgentLaunchSnapshot = Object.freeze({ + version: 1, + requestedAgent: 'codex', + baseAgent: 'codex', + displayLabel: 'Codex', + mode: 'built-in', + argv: ['codex'], + agentEnv: {}, + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} as const) as unknown as AgentLaunchSnapshot + +function admitOne( + store: AgentLaunchAdmissionStore, + principal: AdmissionPrincipal, + scope = 'wt-1', + worktreeId: string | null = null +) { + return store.admit({ + principal, + intent: 'interactive', + scope, + worktreeId, + fingerprint: 'fp', + snapshot: SNAPSHOT, + admittedAt: 1 + }) +} + +describe('AgentLaunchAdmissionStore capacity', () => { + it('caps each principal at 64 pending records', () => { + const store = new AgentLaunchAdmissionStore() + const principal: AdmissionPrincipal = { kind: 'remote', id: 'device-1' } + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) { + expect(admitOne(store, principal).ok).toBe(true) + } + const rejected = admitOne(store, principal) + expect(rejected).toMatchObject({ + ok: false, + failure: { code: 'launch_capacity_exceeded', reason: 'capacity' } + }) + // A different principal still has capacity. + expect(admitOne(store, { kind: 'remote', id: 'device-2' }).ok).toBe(true) + }) + + it('stops remote principals collectively at 192, reserving 64 local slots', () => { + const store = new AgentLaunchAdmissionStore() + for (let device = 0; device < 3; device += 1) { + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) { + expect(admitOne(store, { kind: 'remote', id: `device-${device}` }).ok).toBe(true) + } + } + expect(store.pendingCount()).toBe(MAX_PENDING_LAUNCHES_REMOTE_TOTAL) + expect(admitOne(store, { kind: 'remote', id: 'device-4' }).ok).toBe(false) + // The local host retains its reserved capacity up to the host cap. + let localAdmitted = 0 + while (admitOne(store, { kind: 'local' }).ok) { + localAdmitted += 1 + } + expect(localAdmitted).toBe(MAX_PENDING_LAUNCHES_PER_HOST - MAX_PENDING_LAUNCHES_REMOTE_TOTAL) + expect(store.pendingCount()).toBe(MAX_PENDING_LAUNCHES_PER_HOST) + }) + + it('caps a single worktree at 8 committed launches, independent of other worktrees', () => { + const store = new AgentLaunchAdmissionStore() + const principal: AdmissionPrincipal = { kind: 'local' } + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_WORKTREE; i += 1) { + expect(admitOne(store, principal, `run-${i}`, 'wt-busy').ok).toBe(true) + } + expect(store.pendingForWorktree('wt-busy')).toBe(MAX_PENDING_LAUNCHES_PER_WORKTREE) + // The 9th launch into the same worktree is rejected before any provider I/O. + expect(admitOne(store, principal, 'run-9', 'wt-busy')).toMatchObject({ + ok: false, + failure: { code: 'launch_capacity_exceeded', reason: 'capacity' } + }) + // A different worktree still has its own capacity. + expect(admitOne(store, principal, 'run-other', 'wt-quiet').ok).toBe(true) + // A launch that names no worktree never trips the per-worktree cap. + expect(admitOne(store, principal, 'no-worktree', null).ok).toBe(true) + }) + + it('releasing a worktree launch frees exactly one per-worktree slot', () => { + const store = new AgentLaunchAdmissionStore() + const principal: AdmissionPrincipal = { kind: 'local' } + const admitted = admitOne(store, principal, 'run-0', 'wt-busy') + for (let i = 1; i < MAX_PENDING_LAUNCHES_PER_WORKTREE; i += 1) { + admitOne(store, principal, `run-${i}`, 'wt-busy') + } + expect(admitOne(store, principal, 'run-9', 'wt-busy').ok).toBe(false) + if (!admitted.ok) { + throw new Error('fixture admit failed') + } + expect(store.release(admitted.record.launchToken)).toBe(true) + expect(store.pendingForWorktree('wt-busy')).toBe(MAX_PENDING_LAUNCHES_PER_WORKTREE - 1) + // The freed slot admits again. + expect(admitOne(store, principal, 'run-9', 'wt-busy').ok).toBe(true) + }) + + it('rebuildFrom restores per-worktree counts from durable records', () => { + const store = new AgentLaunchAdmissionStore() + const a = admitOne(store, { kind: 'local' }, 'run-a', 'wt-busy') + const b = admitOne(store, { kind: 'local' }, 'run-b', 'wt-busy') + const c = admitOne(store, { kind: 'local' }, 'run-c', null) + if (!a.ok || !b.ok || !c.ok) { + throw new Error('fixture admit failed') + } + const rebuilt = new AgentLaunchAdmissionStore() + rebuilt.rebuildFrom([a.record, b.record, c.record]) + expect(rebuilt.pendingForWorktree('wt-busy')).toBe(2) + }) + + it('release frees exactly one reservation and unknown tokens are no-ops', () => { + const store = new AgentLaunchAdmissionStore() + const admitted = admitOne(store, { kind: 'local' }) + expect(admitted.ok).toBe(true) + if (!admitted.ok) { + return + } + expect(store.release(admitted.record.launchToken)).toBe(true) + expect(store.release(admitted.record.launchToken)).toBe(false) + expect(store.pendingCount()).toBe(0) + expect(store.pendingForPrincipal({ kind: 'local' })).toBe(0) + }) + + it('rebuilds counters once from durable records', () => { + const store = new AgentLaunchAdmissionStore() + const first = admitOne(store, { kind: 'remote', id: 'device-1' }) + const second = admitOne(store, { kind: 'local' }) + if (!first.ok || !second.ok) { + throw new Error('fixture admit failed') + } + const rebuilt = new AgentLaunchAdmissionStore() + rebuilt.rebuildFrom([first.record, second.record]) + expect(rebuilt.pendingCount()).toBe(2) + expect(rebuilt.pendingForPrincipal({ kind: 'remote', id: 'device-1' })).toBe(1) + expect(rebuilt.pendingForPrincipal({ kind: 'local' })).toBe(1) + }) + + it('summaries stay secret-free and principal-scoped', () => { + const store = new AgentLaunchAdmissionStore() + const mine = admitOne(store, { kind: 'remote', id: 'device-1' }, 'wt-42') + admitOne(store, { kind: 'remote', id: 'device-2' }, 'wt-secret') + expect(mine.ok).toBe(true) + const rows = store.summarizeFor({ kind: 'remote', id: 'device-1' }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ intent: 'interactive', scope: 'wt-42' }) + const text = JSON.stringify(rows) + expect(text).not.toContain('argv') + expect(text).not.toContain('agentEnv') + expect(text).not.toContain('wt-secret') + }) + + it('capacity rows add base harness + host id, stay principal-scoped and secret-free', () => { + const store = new AgentLaunchAdmissionStore() + const mine = admitOne(store, { kind: 'remote', id: 'device-1' }, 'wt-42') + admitOne(store, { kind: 'remote', id: 'device-2' }, 'wt-secret') + expect(mine.ok).toBe(true) + const rows = store.capacitySummaryFor({ kind: 'remote', id: 'device-1' }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + intent: 'interactive', + scope: 'wt-42', + baseHarness: 'codex', + executionHostId: 'local' + }) + const text = JSON.stringify(rows) + // Snapshot secrets never enter the row; only baseAgent + executionHostId do. + expect(text).not.toContain('argv') + expect(text).not.toContain('agentEnv') + expect(text).not.toContain('displayLabel') + expect(text).not.toContain('wt-secret') + }) +}) + +describe('AgentLaunchAdmissionStore reservations', () => { + function reserveOne(store: AgentLaunchAdmissionStore, principal: AdmissionPrincipal) { + return store.reserve(principal) + } + + function admitReservedOne( + store: AgentLaunchAdmissionStore, + reservationId: string, + scope = 'wt-1', + worktreeId: string | null = null + ) { + return store.admitReserved(reservationId, { + intent: 'interactive', + scope, + worktreeId, + fingerprint: 'fp', + snapshot: SNAPSHOT, + admittedAt: 1 + }) + } + + it('reserve holds capacity, and admitReserved converts without double-counting', () => { + const store = new AgentLaunchAdmissionStore() + const reservation = reserveOne(store, { kind: 'local' }) + expect(reservation.ok).toBe(true) + if (!reservation.ok) { + return + } + // The hold counts toward the principal cap before any commit. + expect(store.pendingForPrincipal({ kind: 'local' })).toBe(1) + // pendingCount tracks committed records only; the hold is not committed yet. + expect(store.pendingCount()).toBe(0) + const admitted = admitReservedOne(store, reservation.reservation.reservationId) + expect(admitted.ok).toBe(true) + // Converting a hold does not re-increment: still exactly one for the principal. + expect(store.pendingForPrincipal({ kind: 'local' })).toBe(1) + expect(store.pendingCount()).toBe(1) + }) + + it('admitReserved commits against the now-known worktree and counts toward its cap', () => { + const store = new AgentLaunchAdmissionStore() + const reservation = reserveOne(store, { kind: 'local' }) + expect(reservation.ok).toBe(true) + if (!reservation.ok) { + return + } + // A reservation names no worktree, so the per-worktree count is still 0. + expect(store.pendingForWorktree('wt-new')).toBe(0) + const admitted = admitReservedOne( + store, + reservation.reservation.reservationId, + 'wt-new', + 'wt-new' + ) + expect(admitted.ok).toBe(true) + // Committing binds the launch to the freshly-created worktree. + expect(store.pendingForWorktree('wt-new')).toBe(1) + }) + + it('held reservations count toward the per-principal cap', () => { + const store = new AgentLaunchAdmissionStore() + const principal: AdmissionPrincipal = { kind: 'remote', id: 'device-1' } + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) { + expect(reserveOne(store, principal).ok).toBe(true) + } + // Both a further reserve and a direct admit are rejected once the holds fill. + expect(reserveOne(store, principal).ok).toBe(false) + expect(admitOne(store, principal).ok).toBe(false) + }) + + it('held reservations count toward the collective remote cap', () => { + const store = new AgentLaunchAdmissionStore() + for (let device = 0; device < 3; device += 1) { + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) { + expect(reserveOne(store, { kind: 'remote', id: `device-${device}` }).ok).toBe(true) + } + } + expect(reserveOne(store, { kind: 'remote', id: 'device-4' }).ok).toBe(false) + // Local capacity is still reserved even while remote holds are maxed. + expect(reserveOne(store, { kind: 'local' }).ok).toBe(true) + }) + + it('releaseReservation frees the held slot and unknown ids are no-ops', () => { + const store = new AgentLaunchAdmissionStore() + const reservation = reserveOne(store, { kind: 'local' }) + expect(reservation.ok).toBe(true) + if (!reservation.ok) { + return + } + expect(store.releaseReservation(reservation.reservation.reservationId)).toBe(true) + expect(store.releaseReservation(reservation.reservation.reservationId)).toBe(false) + expect(store.pendingForPrincipal({ kind: 'local' })).toBe(0) + }) + + it('admitReserved fails closed for a released or unknown reservation', () => { + const store = new AgentLaunchAdmissionStore() + const reservation = reserveOne(store, { kind: 'local' }) + expect(reservation.ok).toBe(true) + if (!reservation.ok) { + return + } + store.releaseReservation(reservation.reservation.reservationId) + const admitted = admitReservedOne(store, reservation.reservation.reservationId) + expect(admitted).toMatchObject({ + ok: false, + failure: { code: 'launch_capacity_exceeded', reason: 'capacity' } + }) + expect(admitReservedOne(store, 'never-issued').ok).toBe(false) + }) +}) + +describe('LaunchAdmissionCoordinator', () => { + it('serializes critical sections in FIFO order and survives a throwing section', async () => { + const coordinator = new LaunchAdmissionCoordinator() + const order: number[] = [] + const first = coordinator.runExclusive(() => { + order.push(1) + return 'a' + }) + const failing = coordinator.runExclusive(() => { + order.push(2) + throw new Error('boom') + }) + const third = coordinator.runExclusive(() => { + order.push(3) + return 'c' + }) + await expect(first).resolves.toBe('a') + await expect(failing).rejects.toThrow('boom') + await expect(third).resolves.toBe('c') + expect(order).toEqual([1, 2, 3]) + }) +}) diff --git a/src/main/agent-launch/agent-launch-admission-store.ts b/src/main/agent-launch/agent-launch-admission-store.ts new file mode 100644 index 00000000000..5a74a3e93e7 --- /dev/null +++ b/src/main/agent-launch/agent-launch-admission-store.ts @@ -0,0 +1,355 @@ +// Host-private admitted-pending launch store and the admission coordinator. +// Admission is the launch linearization point (I24): inside one short critical +// section the host revalidates the relevant-input fingerprint and commits the +// token/snapshot/provider intent BEFORE any provider I/O. Records are bounded: +// 256 per host, 64 per authenticated principal, remote principals collectively +// capped so 64 slots stay reserved for local desktop/host work. Rejection is +// launch_capacity_exceeded before provider I/O and before any owner mutation. + +import { randomBytes } from 'node:crypto' +import type { + AgentLaunchExecutionHostId, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' +import type { AgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { AgentLaunchIntentKind } from '../../shared/agent-launch-contract' +import type { BuiltInTuiAgent } from '../../shared/types' + +export const MAX_PENDING_LAUNCHES_PER_HOST = 256 +export const MAX_PENDING_LAUNCHES_PER_PRINCIPAL = 64 +export const MAX_PENDING_LAUNCHES_REMOTE_TOTAL = 192 +// Per-worktree bound (G6): unattended launches (orchestration workers, automation +// runs, background attempts) can pile many pending launches into ONE worktree, so +// a worktree-scoped cap stops a single workspace from monopolizing host capacity. +export const MAX_PENDING_LAUNCHES_PER_WORKTREE = 8 + +/** Stable authenticated principal: the remote caller's clientKind ('mobile' | + * 'runtime') for remote callers, the local desktop/host otherwise. Never a + * per-connection value. + * U10 marker (§U9 ledger #18): despite "id", this is TODAY the coarse clientKind, + * NOT a per-device id — every same-kind paired device shares one principal. Do not + * treat `id` as device-granular until per-device admission principals land (the + * revoked-principal forget override reads revocation at clientKind granularity for + * exactly this reason). */ +export type AdmissionPrincipal = { kind: 'local' } | { kind: 'remote'; id: string } + +export type AdmittedLaunchRecord = { + launchToken: string + principal: AdmissionPrincipal + intent: AgentLaunchIntentKind + /** Owner scope for reconciliation joins (worktree id, pane key, run id …). */ + scope: string + /** Worktree this launch targets, for the per-worktree cap. Null when the + * launch names no worktree (e.g. a not-yet-created two-stage worktree). */ + worktreeId: string | null + fingerprint: string + snapshot: AgentLaunchSnapshot + admittedAt: number +} + +export type AdmissionResult = + | { ok: true; record: AdmittedLaunchRecord } + | { ok: false; failure: AgentLaunchFailure } + +/** Redacted host-side capacity-recovery row for the pending-summary surface. + * Adds only the two non-secret snapshot fields the sheet needs (base harness, + * execution host id) to the summarize set; the launch token stays host-side for + * the liveness scan and is never projected to the client DTO. */ +export type AdmissionCapacityRow = { + intent: AgentLaunchIntentKind + scope: string + admittedAt: number + launchToken: string + baseHarness: BuiltInTuiAgent + executionHostId: AgentLaunchExecutionHostId +} + +/** Fields common to a fresh admit and a reserved admit. Principal comes from the + * request for admit and from the held reservation for admitReserved. */ +export type AgentLaunchAdmitInput = { + intent: AgentLaunchIntentKind + scope: string + /** Target worktree for the per-worktree cap, or null when the launch names no + * worktree yet (a fresh two-stage creation counts trivially against a brand- + * new worktree, so a null-worktree reservation never hits the cap). */ + worktreeId: string | null + fingerprint: string + snapshot: AgentLaunchSnapshot + admittedAt: number +} + +/** A pre-spawn capacity hold taken before git/worktree mutation so a + * launch_capacity_exceeded rejection precedes any side effect. Converted into a + * committed record by admitReserved, or dropped by releaseReservation on any + * pre-spawn exit. Counts toward the caps while held. */ +export type AdmissionReservation = { reservationId: string; principal: AdmissionPrincipal } + +export type ReservationResult = + | { ok: true; reservation: AdmissionReservation } + | { ok: false; failure: AgentLaunchFailure } + +export function principalKey(principal: AdmissionPrincipal): string { + return principal.kind === 'local' ? 'local' : `remote:${principal.id}` +} + +export class AgentLaunchAdmissionStore { + private readonly byToken = new Map() + private readonly countsByPrincipal = new Map() + private readonly countsByWorktree = new Map() + private readonly reservations = new Map() + private remoteTotal = 0 + + /** launch_capacity_exceeded when any cap is at its bound, else null. Held + * reservations count toward the principal/host/remote caps; the per-worktree + * cap counts only committed records (a two-stage reservation has no worktree + * yet). */ + private capacityFailure( + principal: AdmissionPrincipal, + worktreeId: string | null + ): AgentLaunchFailure | null { + const principalCount = this.countsByPrincipal.get(principalKey(principal)) ?? 0 + const worktreeCount = worktreeId ? (this.countsByWorktree.get(worktreeId) ?? 0) : 0 + if ( + this.byToken.size + this.reservations.size >= MAX_PENDING_LAUNCHES_PER_HOST || + principalCount >= MAX_PENDING_LAUNCHES_PER_PRINCIPAL || + // Remote principals collectively stop short of the host cap so local + // desktop/host work always retains reserved capacity. + (principal.kind === 'remote' && this.remoteTotal >= MAX_PENDING_LAUNCHES_REMOTE_TOTAL) || + (worktreeId !== null && worktreeCount >= MAX_PENDING_LAUNCHES_PER_WORKTREE) + ) { + return { code: 'launch_capacity_exceeded', reason: 'capacity' } + } + return null + } + + private incrementCounters(principal: AdmissionPrincipal): void { + const key = principalKey(principal) + this.countsByPrincipal.set(key, (this.countsByPrincipal.get(key) ?? 0) + 1) + if (principal.kind === 'remote') { + this.remoteTotal += 1 + } + } + + private decrementCounters(principal: AdmissionPrincipal): void { + const key = principalKey(principal) + const count = this.countsByPrincipal.get(key) ?? 0 + if (count <= 1) { + this.countsByPrincipal.delete(key) + } else { + this.countsByPrincipal.set(key, count - 1) + } + if (principal.kind === 'remote') { + this.remoteTotal = Math.max(0, this.remoteTotal - 1) + } + } + + /** Per-worktree counters track committed records only. Called when a record + * is committed (admit / admitReserved) and released. */ + private incrementWorktree(worktreeId: string | null): void { + if (!worktreeId) { + return + } + this.countsByWorktree.set(worktreeId, (this.countsByWorktree.get(worktreeId) ?? 0) + 1) + } + + private decrementWorktree(worktreeId: string | null): void { + if (!worktreeId) { + return + } + const count = this.countsByWorktree.get(worktreeId) ?? 0 + if (count <= 1) { + this.countsByWorktree.delete(worktreeId) + } else { + this.countsByWorktree.set(worktreeId, count - 1) + } + } + + /** Commit an admitted-pending record. Call ONLY from inside the coordinator's + * critical section, after the fingerprint recheck passed. */ + admit(input: AgentLaunchAdmitInput & { principal: AdmissionPrincipal }): AdmissionResult { + const failure = this.capacityFailure(input.principal, input.worktreeId) + if (failure) { + return { ok: false, failure } + } + const record: AdmittedLaunchRecord = { + launchToken: randomBytes(24).toString('base64url'), + principal: input.principal, + intent: input.intent, + scope: input.scope, + worktreeId: input.worktreeId, + fingerprint: input.fingerprint, + snapshot: input.snapshot, + admittedAt: input.admittedAt + } + this.byToken.set(record.launchToken, record) + this.incrementCounters(input.principal) + this.incrementWorktree(record.worktreeId) + return { ok: true, record } + } + + /** Take a capacity hold before git/worktree mutation. The pre-create stage + * reserves so a full worktree is never created for an over-cap launch. */ + reserve(principal: AdmissionPrincipal): ReservationResult { + // A pre-create reservation names no worktree yet (it is creating one), so it + // never counts against the per-worktree cap. + const failure = this.capacityFailure(principal, null) + if (failure) { + return { ok: false, failure } + } + const reservationId = randomBytes(18).toString('base64url') + this.reservations.set(reservationId, principal) + this.incrementCounters(principal) + return { ok: true, reservation: { reservationId, principal } } + } + + /** Convert a held reservation into a committed record after the post-create + * fingerprint recheck. Counters already include the reservation, so this + * never re-increments. A lost/expired reservation fails closed. */ + admitReserved(reservationId: string, input: AgentLaunchAdmitInput): AdmissionResult { + const principal = this.reservations.get(reservationId) + if (!principal) { + return { ok: false, failure: { code: 'launch_capacity_exceeded', reason: 'capacity' } } + } + this.reservations.delete(reservationId) + const record: AdmittedLaunchRecord = { + launchToken: randomBytes(24).toString('base64url'), + principal, + intent: input.intent, + scope: input.scope, + worktreeId: input.worktreeId, + fingerprint: input.fingerprint, + snapshot: input.snapshot, + admittedAt: input.admittedAt + } + this.byToken.set(record.launchToken, record) + // The reservation already counted toward principal/host/remote; the worktree + // is known only now (post-create), and a brand-new worktree starts at 0, so + // this commit never trips the per-worktree cap. + this.incrementWorktree(record.worktreeId) + return { ok: true, record } + } + + /** Drop a reservation that never admitted (pre-spawn exit, mismatch, or a + * failed post-create resolution). Frees its held capacity. */ + releaseReservation(reservationId: string): boolean { + const principal = this.reservations.get(reservationId) + if (!principal) { + return false + } + this.reservations.delete(reservationId) + this.decrementCounters(principal) + return true + } + + get(launchToken: string): AdmittedLaunchRecord | null { + return this.byToken.get(launchToken) ?? null + } + + /** Release on receipt (moved to terminal attribution), provider failure, + * admission mismatch, authoritative reconciliation, or explicit forget. + * Never by age while liveness is unknown. */ + release(launchToken: string): boolean { + const record = this.byToken.get(launchToken) + if (!record) { + return false + } + this.byToken.delete(launchToken) + this.decrementCounters(record.principal) + this.decrementWorktree(record.worktreeId) + return true + } + + /** Rebuild counters from durable pending records once at startup; later + * transitions update counters incrementally rather than rescanning. + * Reservations are ephemeral pre-spawn holds and never persist, so a rebuild + * starts with none. */ + rebuildFrom(records: Iterable): void { + this.byToken.clear() + this.countsByPrincipal.clear() + this.countsByWorktree.clear() + this.reservations.clear() + this.remoteTotal = 0 + for (const record of records) { + this.byToken.set(record.launchToken, record) + this.incrementCounters(record.principal) + this.incrementWorktree(record.worktreeId) + } + } + + pendingCount(): number { + return this.byToken.size + } + + pendingForPrincipal(principal: AdmissionPrincipal): number { + return this.countsByPrincipal.get(principalKey(principal)) ?? 0 + } + + pendingForWorktree(worktreeId: string): number { + return this.countsByWorktree.get(worktreeId) ?? 0 + } + + /** Secret-free rows for the capacity-recovery surface: never snapshot, argv, + * env, prompt, label, or the token of another principal's row. */ + summarizeFor(principal: AdmissionPrincipal): { + intent: AgentLaunchIntentKind + scope: string + admittedAt: number + launchToken: string + }[] { + const key = principalKey(principal) + const rows: { + intent: AgentLaunchIntentKind + scope: string + admittedAt: number + launchToken: string + }[] = [] + for (const record of this.byToken.values()) { + if (principalKey(record.principal) === key) { + rows.push({ + intent: record.intent, + scope: record.scope, + admittedAt: record.admittedAt, + launchToken: record.launchToken + }) + } + } + return rows + } + + /** Redacted capacity-recovery rows for one principal: the summarize set plus the + * two non-secret snapshot fields the sheet needs. Filters strictly to the + * principal's own records; never another principal's row. */ + capacitySummaryFor(principal: AdmissionPrincipal): AdmissionCapacityRow[] { + const key = principalKey(principal) + const rows: AdmissionCapacityRow[] = [] + for (const record of this.byToken.values()) { + if (principalKey(record.principal) === key) { + rows.push({ + intent: record.intent, + scope: record.scope, + admittedAt: record.admittedAt, + launchToken: record.launchToken, + baseHarness: record.snapshot.baseAgent, + executionHostId: record.snapshot.target.executionHostId + }) + } + } + return rows + } +} + +/** Short async critical section shared by launch admission and every mutation + * of admission-relevant inputs. No trust, filesystem, network, home lookup, or + * provider call may run while held — callers do I/O before/after, never inside. */ +export class LaunchAdmissionCoordinator { + private tail: Promise = Promise.resolve() + + runExclusive(critical: () => T): Promise { + const run = this.tail.then(() => critical()) + this.tail = run.then( + () => undefined, + () => undefined + ) + return run + } +} diff --git a/src/main/agent-launch/agent-launch-boundary-contract.ts b/src/main/agent-launch/agent-launch-boundary-contract.ts new file mode 100644 index 00000000000..ebb7aeb32b4 --- /dev/null +++ b/src/main/agent-launch/agent-launch-boundary-contract.ts @@ -0,0 +1,198 @@ +// Request/result contract for the agent-launch host boundary (U3/U4). Split from +// agent-launch-boundary.ts so the boundary class stays within the module size +// budget; the boundary re-exports these, so existing importers are unaffected. + +import type { AdmissionPrincipal } from './agent-launch-admission-store' +import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import type { ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract' +import type { + AgentLaunchFailure, + AgentLaunchNotice, + AgentLaunchReceipt, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import { buildAgentStartupPlanFromResolvedLaunch } from '../../shared/resolved-agent-startup-plan' + +/** Collapse duplicate notice codes so a receipt carries each notice once. */ +export function dedupeNoticesByCode(notices: readonly AgentLaunchNotice[]): AgentLaunchNotice[] { + const seen = new Set() + const deduped: AgentLaunchNotice[] = [] + for (const notice of notices) { + if (!seen.has(notice.code)) { + seen.add(notice.code) + deduped.push(notice) + } + } + return deduped +} + +/** Map a re-resolve mismatch inside the coordinator: a newly disabled base is + * base_agent_disabled; any other relevant change is agent_configuration_changed. + * The fingerprint only hashes relevant inputs, so an unrelated catalog edit does + * not reach this path. */ +export function mapAdmissionMismatch(failure: AgentLaunchFailure): AgentLaunchFailure { + if (failure.code === 'base_agent_disabled') { + return failure + } + return { + code: 'agent_configuration_changed', + ...(failure.requestedAgent ? { requestedAgent: failure.requestedAgent } : {}), + ...(failure.baseAgent ? { baseAgent: failure.baseAgent } : {}) + } +} + +/** The client-safe identity pair carried on failures/receipts. */ +export function agentIds(launch: ResolvedAgentLaunch): { + requestedAgent: ResolvedAgentLaunch['requestedAgent'] + baseAgent: ResolvedAgentLaunch['baseAgent'] +} { + return { requestedAgent: launch.requestedAgent, baseAgent: launch.baseAgent } +} + +/** Authenticated RPC client kind. `undefined` is an in-process/host caller — + * desktop, never mobile by guesswork. Never copied from client JSON. */ +export type AuthenticatedClientKind = 'runtime' | 'mobile' | undefined + +/** Map the authenticated RPC scope to the launch-intent client. Callers build + * their interactive/resume LaunchIntent host-side with this — the boundary's + * intent construction lives here so no path derives it from client payload. */ +export function mapClientKindToLaunchClient( + kind: AuthenticatedClientKind +): 'desktop' | 'paired-web' | 'mobile' { + if (kind === 'runtime') { + return 'paired-web' + } + if (kind === 'mobile') { + return 'mobile' + } + return 'desktop' +} + +/** One resolution against the current atomic host state view (settings + + * normalized catalog + detection snapshot + derived target). The caller closes + * over the fixed request (selection/intent/reference/variables/target) and + * re-reads volatile host state on each call; it performs no async I/O so it is + * safe to invoke inside the coordinator's critical section. */ +export type HostStateResolution = { + outcome: ResolveAgentLaunchOutcome + catalogRevision: number +} + +export type ExecuteAgentLaunchArgs = { + /** Owner scope for reconciliation joins (worktree id, pane key, run id …). */ + scope: string + /** Target worktree for the per-worktree admission cap. Omit/null when the + * launch names no worktree (the scope already IS the worktree for interactive + * worktree launches, but unattended launches scope by run/dispatch/attempt id + * and must name the worktree separately). */ + worktreeId?: string | null + principal: AdmissionPrincipal + /** Re-resolve from a fresh atomic host view. Called once before admission and + * once inside the coordinator; both re-read settings. */ + resolve: () => HostStateResolution + prompt: string + allowEmptyPromptLaunch?: boolean + /** 'draft' lands the prompt unsubmitted; default 'submit'. */ + promptDelivery?: 'submit' | 'draft' + /** Inline draft-flag command ceiling (STARTUP_COMMAND_TEXT_MAX_CHARS), threaded + * from the provider layer so the shared plan builder stays main-free. */ + maxInlineDraftChars?: number + /** Trust preflight, OUTSIDE the coordinator. A throw maps to + * trust_preflight_failed and commits no admission record. */ + preflight?: (launch: ResolvedAgentLaunch) => Promise | void + /** Provider env preparation, OUTSIDE the coordinator. Same failure mapping as + * preflight: a pre-spawn preparation throw is a trust_preflight_failed with + * no admission record (no dedicated failure code exists for this phase). */ + prepareEnv?: (launch: ResolvedAgentLaunch) => Promise | void + now?: () => number +} + +export type ExecuteAgentLaunchResult = + | { ok: true; plan: AgentStartupPlan; receipt: AgentLaunchReceipt } + | { ok: false; failure: AgentLaunchFailure } + | { ok: false; requestError: AgentLaunchRequestError } + +/** Resolve-only startup-plan request for the legacy renderer-spawned worktree- + * create path. It resolves once against the atomic host view and builds a plan, + * but takes NO admission token — that path registers no terminal receipt and has + * no settle seam, so an admitted hold would leak capacity forever. */ +export type ResolveAgentLaunchPlanArgs = { + resolve: () => HostStateResolution + prompt: string + allowEmptyPromptLaunch?: boolean + promptDelivery?: 'submit' | 'draft' + maxInlineDraftChars?: number +} + +export type ResolveAgentLaunchPlanResult = + | { ok: true; plan: AgentStartupPlan } + | { ok: false; failure: AgentLaunchFailure } + | { ok: false; requestError: AgentLaunchRequestError } + +/** Resolve once and build a startup plan without admitting. Extracted from the + * boundary class because it holds no admission/coordinator state — it is the + * legacy path's whole pipeline. */ +export function resolveAgentLaunchPlanWithoutAdmission( + args: ResolveAgentLaunchPlanArgs +): ResolveAgentLaunchPlanResult { + const resolution = args.resolve() + if (!resolution.outcome.ok) { + if ('requestError' in resolution.outcome) { + return { ok: false, requestError: resolution.outcome.requestError } + } + return { ok: false, failure: resolution.outcome.failure } + } + const original = resolution.outcome.launch + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch: original, + prompt: args.prompt, + ...(args.allowEmptyPromptLaunch !== undefined + ? { allowEmptyPromptLaunch: args.allowEmptyPromptLaunch } + : {}), + ...(args.promptDelivery !== undefined ? { promptDelivery: args.promptDelivery } : {}), + ...(args.maxInlineDraftChars !== undefined + ? { maxInlineDraftChars: args.maxInlineDraftChars } + : {}) + // No launchToken: nothing is admitted, so there is nothing to reconcile. + }) + if (!plan) { + return { ok: false, failure: { code: 'no_agent_selected', ...agentIds(original) } } + } + return { ok: true, plan } +} + +export type PrepareReservedAgentLaunchArgs = { + principal: AdmissionPrincipal + /** Resolve selection (may be `default`) against the atomic host view with + * provisional variables — the worktree path is not yet authoritative. Only + * the pinned identity + config-only digest survive; the argv is discarded. */ + resolve: () => HostStateResolution +} + +/** Pre-create outcome held across git mutation. The reservation must be + * converted by executeReservedAgentLaunch or dropped via releaseReservation on + * every pre-spawn exit; the caller owns that lifecycle. */ +export type PrepareReservedAgentLaunchResult = + | { + ok: true + reservationId: string + requestedAgent: ResolvedAgentLaunch['requestedAgent'] + baseAgent: ResolvedAgentLaunch['baseAgent'] + stableInputDigest: string + } + | { ok: false; failure: AgentLaunchFailure } + | { ok: false; requestError: AgentLaunchRequestError } + +export type ExecuteReservedAgentLaunchArgs = ExecuteAgentLaunchArgs & { + /** The hold taken by prepareReservedAgentLaunch. */ + reservationId: string + /** The config-only digest pinned pre-create; a post-create mismatch means the + * definition/default/base changed across the git operation. */ + expectedStableInputDigest: string +} + +/** Terminal outcome the caller boundary reports back so admission moves or + * releases: `registered` keeps a private reconciliation handoff record, while + * `failed` releases the reservation entirely. */ +export type LaunchSettlement = 'registered' | 'failed' diff --git a/src/main/agent-launch/agent-launch-boundary-host.ts b/src/main/agent-launch/agent-launch-boundary-host.ts new file mode 100644 index 00000000000..d0ae7dcb47e --- /dev/null +++ b/src/main/agent-launch/agent-launch-boundary-host.ts @@ -0,0 +1,22 @@ +// Host-wide singleton launch boundary. Admission bounds (256 host / 64 principal +// / 192 remote) are host-scoped, not per-profile, so one boundary — with one +// admission store and one coordinator — serves every launch surface. U4 attaches +// durable persistence; U3 uses the in-memory boundary. + +import { AgentLaunchBoundary } from './agent-launch-boundary' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator +} from './agent-launch-admission-store' + +let boundary: AgentLaunchBoundary | null = null + +export function getHostAgentLaunchBoundary(): AgentLaunchBoundary { + if (!boundary) { + boundary = new AgentLaunchBoundary({ + admissionStore: new AgentLaunchAdmissionStore(), + coordinator: new LaunchAdmissionCoordinator() + }) + } + return boundary +} diff --git a/src/main/agent-launch/agent-launch-boundary.test.ts b/src/main/agent-launch/agent-launch-boundary.test.ts new file mode 100644 index 00000000000..710f12be0b1 --- /dev/null +++ b/src/main/agent-launch/agent-launch-boundary.test.ts @@ -0,0 +1,683 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AgentLaunchBoundary, + mapClientKindToLaunchClient, + type HostStateResolution +} from './agent-launch-boundary' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator, + MAX_PENDING_LAUNCHES_PER_PRINCIPAL, + type AdmissionPrincipal +} from './agent-launch-admission-store' +import type { + ResolvedAgentLaunch, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch' + +const LOCAL_PRINCIPAL: AdmissionPrincipal = { kind: 'local' } + +function makeSnapshot(overrides: Partial = {}): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['/bin/secretexe', '--flag'], + agentEnv: { SECRET_ENV: 'topsecret-value' }, + capturedEnvPolicy: 'full', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + }, + ...overrides + } +} + +function makeLaunch( + fingerprint: string, + overrides: Partial = {} +): ResolvedAgentLaunch { + const snapshot = overrides.snapshot ?? makeSnapshot() + return { + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + argv: snapshot.argv, + agentEnv: snapshot.agentEnv, + variables: { values: { repoPath: null, worktreePath: null }, referenced: [] }, + snapshot, + policy: { + intent: 'interactive', + mode: 'built-in', + client: 'desktop', + isRemote: false, + platform: 'linux', + promptInjectionMode: 'stdin-after-start', + expectedProcess: 'claude', + env: 'full' + }, + notices: [], + telemetry: { agentKind: 'claude-code', usedCustomAgent: false }, + admissionGuard: { fingerprint, stableInputDigest: fingerprint, basis: 'explicit' }, + ...overrides + } +} + +function okResolution(launch: ResolvedAgentLaunch, catalogRevision = 1): HostStateResolution { + return { outcome: { ok: true, launch }, catalogRevision } +} + +function failureResolution( + outcome: Extract, + catalogRevision = 1 +): HostStateResolution { + return { outcome, catalogRevision } +} + +function makeBoundary(): { + boundary: AgentLaunchBoundary + store: AgentLaunchAdmissionStore +} { + const store = new AgentLaunchAdmissionStore() + const boundary = new AgentLaunchBoundary({ + admissionStore: store, + coordinator: new LaunchAdmissionCoordinator(), + now: () => 1000 + }) + return { boundary, store } +} + +describe('mapClientKindToLaunchClient', () => { + it('maps runtime to paired-web, mobile to mobile, undefined to desktop', () => { + expect(mapClientKindToLaunchClient('runtime')).toBe('paired-web') + expect(mapClientKindToLaunchClient('mobile')).toBe('mobile') + expect(mapClientKindToLaunchClient(undefined)).toBe('desktop') + }) +}) + +describe('AgentLaunchBoundary.executeAgentLaunch', () => { + it('resolves for the plan once and admits the original snapshot', async () => { + const { boundary, store } = makeBoundary() + const original = makeLaunch('fp-1') + // Second resolve returns a distinct launch object with the SAME fingerprint + // (an unrelated catalog edit). The admitted snapshot must be the original. + const reResolveLaunch = makeLaunch('fp-1', { + snapshot: makeSnapshot({ displayLabel: 'edited' }) + }) + const resolve = vi + .fn<() => HostStateResolution>() + .mockReturnValueOnce(okResolution(original)) + .mockReturnValueOnce(okResolution(reResolveLaunch, 2)) + + const result = await boundary.executeAgentLaunch({ + scope: 'worktree-1', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: '', + allowEmptyPromptLaunch: true + }) + + expect(resolve).toHaveBeenCalledTimes(2) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const admitted = store.get(result.receipt.launchToken) + expect(admitted?.snapshot).toBe(original.snapshot) + expect(result.receipt.catalogRevision).toBe(2) + }) + + it('never serializes snapshot argv/env, fingerprint, or digest into the launched receipt', async () => { + const { boundary } = makeBoundary() + const launch = makeLaunch('fp-secret', { + admissionGuard: { + fingerprint: 'fp-secret', + stableInputDigest: 'digest-secret', + basis: 'explicit' + } + }) + + const result = await boundary.executeAgentLaunch({ + scope: 'worktree-secret', + principal: LOCAL_PRINCIPAL, + resolve: () => okResolution(launch), + prompt: '', + allowEmptyPromptLaunch: true + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + // The receipt is the ONLY agent-launch payload that crosses to clients, so it + // must carry the client-safe identity/notices/token only — never the host- + // private snapshot argv/env, the relevant-input fingerprint, or the digest. + expect(Object.keys(result.receipt).sort()).toEqual([ + 'baseAgent', + 'catalogRevision', + 'launchToken', + 'notices', + 'requestedAgent', + 'telemetry' + ]) + // Oracle 17: the receipt's telemetry marker is client-safe — the base kind + // enum and a boolean only, never the requested (possibly custom) id or label. + expect(Object.keys(result.receipt.telemetry).sort()).toEqual(['agentKind', 'usedCustomAgent']) + expect(typeof result.receipt.telemetry.usedCustomAgent).toBe('boolean') + const serialized = JSON.stringify(result.receipt) + expect(serialized).not.toContain('secretexe') // snapshot argv executable + expect(serialized).not.toContain('topsecret-value') // snapshot agentEnv value + expect(serialized).not.toContain('SECRET_ENV') // snapshot agentEnv key + expect(serialized).not.toContain('fp-secret') // relevant-input fingerprint + expect(serialized).not.toContain('digest-secret') // config-only digest + }) + + it('returns the initial failure without admitting', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi.fn(() => + failureResolution({ ok: false, failure: { code: 'no_agent_selected' } }) + ) + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + expect(result).toEqual({ ok: false, failure: { code: 'no_agent_selected' } }) + expect(resolve).toHaveBeenCalledTimes(1) + expect(store.pendingCount()).toBe(0) + }) + + it('returns an initial request error without admitting', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi.fn(() => + failureResolution({ ok: false, requestError: { code: 'untrusted_reference' } }) + ) + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + expect(result).toEqual({ ok: false, requestError: { code: 'untrusted_reference' } }) + expect(store.pendingCount()).toBe(0) + }) + + it('maps a base disable that wins the admission race to base_agent_disabled with no reservation', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi + .fn<() => HostStateResolution>() + .mockReturnValueOnce(okResolution(makeLaunch('fp-1'))) + // Mutation committed between resolve and admit: base is now disabled. + .mockReturnValueOnce( + failureResolution({ + ok: false, + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + }) + ) + + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('base_agent_disabled') + expect(store.pendingCount()).toBe(0) + }) + + it('maps any other relevant change to agent_configuration_changed', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi + .fn<() => HostStateResolution>() + .mockReturnValueOnce(okResolution(makeLaunch('fp-1'))) + // Different fingerprint: a relevant input changed (definition/default/env). + .mockReturnValueOnce(okResolution(makeLaunch('fp-2'))) + + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('agent_configuration_changed') + expect(store.pendingCount()).toBe(0) + }) + + it('proceeds when only an unrelated agent changed (fingerprint unchanged)', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi + .fn<() => HostStateResolution>() + .mockReturnValueOnce(okResolution(makeLaunch('fp-1'))) + .mockReturnValueOnce(okResolution(makeLaunch('fp-1'))) + + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + + expect(result.ok).toBe(true) + expect(store.pendingCount()).toBe(1) + }) + + it('fails trust_preflight_failed on a thrown preflight and admits nothing', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi.fn(() => okResolution(makeLaunch('fp-1'))) + const preflight = vi.fn(() => { + throw new Error('trust denied') + }) + + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi', + preflight + }) + + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('trust_preflight_failed') + expect(preflight).toHaveBeenCalledTimes(1) + // No re-resolve happened because we never entered the coordinator. + expect(resolve).toHaveBeenCalledTimes(1) + expect(store.pendingCount()).toBe(0) + }) + + it('fails trust_preflight_failed on a thrown provider env preparation hook', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi.fn(() => okResolution(makeLaunch('fp-1'))) + const prepareEnv = vi.fn(async () => { + throw new Error('env prep failed') + }) + + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi', + prepareEnv + }) + + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('trust_preflight_failed') + expect(store.pendingCount()).toBe(0) + }) + + it('rejects with launch_capacity_exceeded before producing a plan', async () => { + const { boundary, store } = makeBoundary() + for (let index = 0; index < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; index += 1) { + store.admit({ + principal: LOCAL_PRINCIPAL, + intent: 'interactive', + scope: `filler-${index}`, + worktreeId: null, + fingerprint: 'x', + snapshot: makeSnapshot(), + admittedAt: 1 + }) + } + const resolve = vi.fn(() => okResolution(makeLaunch('fp-1'))) + + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('launch_capacity_exceeded') + expect('plan' in result).toBe(false) + }) + + it('produces a receipt free of argv, env, and snapshot material', async () => { + const { boundary } = makeBoundary() + const resolve = vi.fn(() => okResolution(makeLaunch('fp-1'))) + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'do the thing' + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const serialized = JSON.stringify(result.receipt) + expect(serialized).not.toContain('topsecret-value') + expect(serialized).not.toContain('secretexe') + expect(serialized).not.toContain('SECRET_ENV') + expect(result.receipt.launchToken.length).toBeGreaterThan(0) + // The plan carries the token; the receipt echoes it for the caller. + expect(result.plan.launchToken).toBe(result.receipt.launchToken) + }) + + it('deduplicates receipt notices by code', async () => { + const { boundary } = makeBoundary() + const launch = makeLaunch('fp-1', { + notices: [ + { code: 'env_withheld', label: 'Claude' }, + { code: 'env_withheld', label: 'Claude' }, + { code: 'snapshot_definition_changed', label: 'Claude' } + ] + }) + const resolve = vi.fn(() => okResolution(launch)) + const result = await boundary.executeAgentLaunch({ + scope: 's', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.receipt.notices.map((notice) => notice.code)).toEqual([ + 'env_withheld', + 'snapshot_definition_changed' + ]) + }) +}) + +describe('AgentLaunchBoundary.settleAgentLaunch', () => { + it('registered retains a private handoff record and frees the reservation', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi.fn(() => okResolution(makeLaunch('fp-1'))) + const result = await boundary.executeAgentLaunch({ + scope: 'worktree-9', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const token = result.receipt.launchToken + boundary.settleAgentLaunch(token, 'registered') + expect(store.get(token)).toBeNull() + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0) + expect(boundary.retainedFor(token)?.scope).toBe('worktree-9') + }) + + it('failed releases the reservation and retains nothing', async () => { + const { boundary, store } = makeBoundary() + const resolve = vi.fn(() => okResolution(makeLaunch('fp-1'))) + const result = await boundary.executeAgentLaunch({ + scope: 'worktree-9', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi' + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const token = result.receipt.launchToken + boundary.settleAgentLaunch(token, 'failed') + expect(store.get(token)).toBeNull() + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0) + expect(boundary.retainedFor(token)).toBeNull() + }) +}) + +describe('AgentLaunchBoundary.resolveAgentLaunchPlanWithoutAdmission', () => { + it('resolves once and builds a plan without an admission token or capacity hold', () => { + const { boundary, store } = makeBoundary() + const resolve = vi + .fn<() => HostStateResolution>() + .mockReturnValue(okResolution(makeLaunch('fp-1'))) + + const result = boundary.resolveAgentLaunchPlanWithoutAdmission({ + resolve, + prompt: '', + allowEmptyPromptLaunch: true + }) + + // The legacy path resolves exactly once — no coordinator re-resolve — and + // never admits: the plan carries no launchToken and no capacity is held. + expect(resolve).toHaveBeenCalledTimes(1) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.plan.launchToken).toBeUndefined() + // All capacity is still free: nothing was reserved or admitted. + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i++) { + expect(store.reserve(LOCAL_PRINCIPAL).ok).toBe(true) + } + }) + + it('returns the resolver failure without holding capacity', () => { + const { boundary, store } = makeBoundary() + const resolve = vi + .fn<() => HostStateResolution>() + .mockReturnValue(failureResolution({ ok: false, failure: { code: 'base_agent_disabled' } })) + + const result = boundary.resolveAgentLaunchPlanWithoutAdmission({ resolve, prompt: 'hi' }) + + expect(resolve).toHaveBeenCalledTimes(1) + expect(result).toEqual({ ok: false, failure: { code: 'base_agent_disabled' } }) + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i++) { + expect(store.reserve(LOCAL_PRINCIPAL).ok).toBe(true) + } + }) + + it('passes through a request-error resolution', () => { + const { boundary } = makeBoundary() + const result = boundary.resolveAgentLaunchPlanWithoutAdmission({ + resolve: () => + failureResolution({ ok: false, requestError: { code: 'stale_agent_launch_failure' } }), + prompt: 'hi' + }) + expect(result).toEqual({ + ok: false, + requestError: { code: 'stale_agent_launch_failure' } + }) + }) +}) + +describe('AgentLaunchBoundary two-stage reserved launch', () => { + function digestLaunch(fingerprint: string, stableInputDigest: string): ResolvedAgentLaunch { + return makeLaunch(fingerprint, { + admissionGuard: { fingerprint, stableInputDigest, basis: 'explicit' } + }) + } + + function prepareHold( + boundary: AgentLaunchBoundary, + launch: ResolvedAgentLaunch + ): { reservationId: string; stableInputDigest: string } { + const prepared = boundary.prepareReservedAgentLaunch({ + principal: LOCAL_PRINCIPAL, + resolve: () => okResolution(launch) + }) + if (!prepared.ok) { + throw new Error('expected prepare to succeed') + } + return { reservationId: prepared.reservationId, stableInputDigest: prepared.stableInputDigest } + } + + it('prepare pins identity + digest and holds one reservation before git', () => { + const { boundary, store } = makeBoundary() + const prepared = boundary.prepareReservedAgentLaunch({ + principal: LOCAL_PRINCIPAL, + resolve: () => okResolution(digestLaunch('fp-1', 'stable-A')) + }) + expect(prepared.ok).toBe(true) + if (!prepared.ok) { + return + } + expect(prepared.requestedAgent).toBe('claude') + expect(prepared.stableInputDigest).toBe('stable-A') + // The hold counts toward capacity but is not a committed token yet. + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(1) + expect(store.pendingCount()).toBe(0) + }) + + it('prepare takes no reservation when the pin resolve fails', () => { + const { boundary, store } = makeBoundary() + const prepared = boundary.prepareReservedAgentLaunch({ + principal: LOCAL_PRINCIPAL, + resolve: () => failureResolution({ ok: false, failure: { code: 'no_agent_selected' } }) + }) + expect(prepared.ok).toBe(false) + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0) + }) + + it('prepare rejects launch_capacity_exceeded without leaking a hold', () => { + const { boundary, store } = makeBoundary() + for (let index = 0; index < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; index += 1) { + store.reserve(LOCAL_PRINCIPAL) + } + const prepared = boundary.prepareReservedAgentLaunch({ + principal: LOCAL_PRINCIPAL, + resolve: () => okResolution(makeLaunch('fp-1')) + }) + expect(prepared.ok).toBe(false) + if (prepared.ok) { + return + } + expect('failure' in prepared && prepared.failure.code).toBe('launch_capacity_exceeded') + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(MAX_PENDING_LAUNCHES_PER_PRINCIPAL) + }) + + it('executeReserved converts the hold to exactly one token on success', async () => { + const { boundary, store } = makeBoundary() + const launch = digestLaunch('fp-1', 'stable-A') + const { reservationId, stableInputDigest } = prepareHold(boundary, launch) + const result = await boundary.executeReservedAgentLaunch({ + scope: 'wt-1', + principal: LOCAL_PRINCIPAL, + resolve: () => okResolution(launch), + prompt: 'hi', + reservationId, + expectedStableInputDigest: stableInputDigest + }) + expect(result.ok).toBe(true) + // Converted, not double-counted: one committed record, no dangling hold. + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(1) + expect(store.pendingCount()).toBe(1) + if (result.ok) { + expect(store.get(result.receipt.launchToken)?.snapshot).toBe(launch.snapshot) + } + }) + + it('executeReserved releases the hold when the post-create config digest differs', async () => { + const { boundary, store } = makeBoundary() + const { reservationId } = prepareHold(boundary, digestLaunch('fp-1', 'stable-A')) + const resolve = vi.fn(() => okResolution(digestLaunch('fp-2', 'stable-B'))) + const result = await boundary.executeReservedAgentLaunch({ + scope: 'wt-1', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi', + reservationId, + expectedStableInputDigest: 'stable-A' + }) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('agent_configuration_changed') + // Hold released, no capacity leaked; rejected before entering the coordinator. + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0) + expect(resolve).toHaveBeenCalledTimes(1) + }) + + it('executeReserved releases the hold when the post-create resolve fails', async () => { + const { boundary, store } = makeBoundary() + const { reservationId } = prepareHold(boundary, digestLaunch('fp-1', 'stable-A')) + const result = await boundary.executeReservedAgentLaunch({ + scope: 'wt-1', + principal: LOCAL_PRINCIPAL, + resolve: () => + failureResolution({ + ok: false, + failure: { code: 'missing_variable', variable: 'worktreePath' } + }), + prompt: 'hi', + reservationId, + expectedStableInputDigest: 'stable-A' + }) + expect(result.ok).toBe(false) + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0) + }) + + it('executeReserved releases the hold on a thrown preflight', async () => { + const { boundary, store } = makeBoundary() + const launch = digestLaunch('fp-1', 'stable-A') + const { reservationId } = prepareHold(boundary, launch) + const result = await boundary.executeReservedAgentLaunch({ + scope: 'wt-1', + principal: LOCAL_PRINCIPAL, + resolve: () => okResolution(launch), + prompt: 'hi', + preflight: () => { + throw new Error('trust denied') + }, + reservationId, + expectedStableInputDigest: 'stable-A' + }) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('trust_preflight_failed') + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0) + }) + + it('executeReserved releases the hold on an in-coordinator fingerprint mismatch', async () => { + const { boundary, store } = makeBoundary() + const pinned = digestLaunch('fp-1', 'stable-A') + const { reservationId } = prepareHold(boundary, pinned) + // Pre-coordinator resolve matches the pin; the in-coordinator re-resolve keeps + // the same config digest but a changed fingerprint (a relevant edit committed). + const resolve = vi + .fn<() => HostStateResolution>() + .mockReturnValueOnce(okResolution(pinned)) + .mockReturnValueOnce(okResolution(digestLaunch('fp-9', 'stable-A'))) + const result = await boundary.executeReservedAgentLaunch({ + scope: 'wt-1', + principal: LOCAL_PRINCIPAL, + resolve, + prompt: 'hi', + reservationId, + expectedStableInputDigest: 'stable-A' + }) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect('failure' in result && result.failure.code).toBe('agent_configuration_changed') + expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0) + expect(resolve).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/agent-launch/agent-launch-boundary.ts b/src/main/agent-launch/agent-launch-boundary.ts new file mode 100644 index 00000000000..84ad0c54b7e --- /dev/null +++ b/src/main/agent-launch/agent-launch-boundary.ts @@ -0,0 +1,351 @@ +// The single host boundary every agent spawn routes through (U3). It sequences +// the launch pipeline exactly per plan §3's admission paragraph: resolve once +// from an atomic host view, run trust/provider-env preparation OUTSIDE the +// admission coordinator, then INSIDE the coordinator re-take the host view, +// recompute the relevant-input fingerprint, and commit the admitted token/ +// snapshot before any provider I/O. The startup plan is built from the ORIGINAL +// resolved launch: admission commits that snapshot and later edits affect only +// future launches. Dependency-injected and electron-free so it is unit-testable. + +import type { + AdmissionCapacityRow, + AdmissionPrincipal, + AgentLaunchAdmissionStore, + AdmittedLaunchRecord, + LaunchAdmissionCoordinator +} from './agent-launch-admission-store' +import type { ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract' +import type { + AgentLaunchFailure, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import { buildAgentStartupPlanFromResolvedLaunch } from '../../shared/resolved-agent-startup-plan' +import { + agentIds, + dedupeNoticesByCode, + mapAdmissionMismatch, + resolveAgentLaunchPlanWithoutAdmission, + type ExecuteAgentLaunchArgs, + type ExecuteAgentLaunchResult, + type ExecuteReservedAgentLaunchArgs, + type LaunchSettlement, + type PrepareReservedAgentLaunchArgs, + type PrepareReservedAgentLaunchResult, + type ResolveAgentLaunchPlanArgs, + type ResolveAgentLaunchPlanResult +} from './agent-launch-boundary-contract' + +// Re-export the boundary contract so existing importers keep a single entry. +export * from './agent-launch-boundary-contract' + +type CriticalResult = + | { kind: 'admitted'; record: AdmittedLaunchRecord; catalogRevision: number } + | { kind: 'failure'; failure: AgentLaunchFailure } + | { kind: 'requestError'; requestError: AgentLaunchRequestError } + +export class AgentLaunchBoundary { + private readonly admissionStore: AgentLaunchAdmissionStore + private readonly coordinator: LaunchAdmissionCoordinator + private readonly now: () => number + /** Private reconciliation handoff for registered launches; U4 replaces this + * with the durable operation ledger. Never serialized to clients/logs. */ + private readonly retained = new Map() + + constructor(deps: { + admissionStore: AgentLaunchAdmissionStore + coordinator: LaunchAdmissionCoordinator + now?: () => number + }) { + this.admissionStore = deps.admissionStore + this.coordinator = deps.coordinator + this.now = deps.now ?? (() => Date.now()) + } + + async executeAgentLaunch(args: ExecuteAgentLaunchArgs): Promise { + const nowFn = args.now ?? this.now + const initial = args.resolve() + if (!initial.outcome.ok) { + if ('requestError' in initial.outcome) { + return { ok: false, requestError: initial.outcome.requestError } + } + return { ok: false, failure: initial.outcome.failure } + } + const original = initial.outcome.launch + const originalFingerprint = original.admissionGuard.fingerprint + + const prepFailure = await this.runPreparation(args, original) + if (prepFailure) { + return { ok: false, failure: prepFailure } + } + + const result = await this.coordinator.runExclusive(() => + this.admitInsideCoordinator(args.resolve, original, originalFingerprint, () => + this.admissionStore.admit({ + principal: args.principal, + intent: original.policy.intent, + scope: args.scope, + worktreeId: args.worktreeId ?? null, + fingerprint: originalFingerprint, + snapshot: original.snapshot, + admittedAt: nowFn() + }) + ) + ) + if (result.kind === 'requestError') { + return { ok: false, requestError: result.requestError } + } + if (result.kind === 'failure') { + return { ok: false, failure: result.failure } + } + + return this.finalizeAdmittedLaunch( + args, + original, + result.record.launchToken, + result.catalogRevision + ) + } + + /** Build the startup plan from the ORIGINAL admitted launch and assemble the + * receipt. Shared by the single-shot and two-stage paths. A null plan + * (nothing launchable) releases the admitted token rather than stranding it. */ + private finalizeAdmittedLaunch( + args: ExecuteAgentLaunchArgs, + original: ResolvedAgentLaunch, + token: string, + catalogRevision: number + ): ExecuteAgentLaunchResult { + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch: original, + prompt: args.prompt, + ...(args.allowEmptyPromptLaunch !== undefined + ? { allowEmptyPromptLaunch: args.allowEmptyPromptLaunch } + : {}), + ...(args.promptDelivery !== undefined ? { promptDelivery: args.promptDelivery } : {}), + ...(args.maxInlineDraftChars !== undefined + ? { maxInlineDraftChars: args.maxInlineDraftChars } + : {}), + launchToken: token + }) + if (!plan) { + this.admissionStore.release(token) + return { + ok: false, + failure: { + code: 'no_agent_selected', + requestedAgent: original.requestedAgent, + baseAgent: original.baseAgent + } + } + } + return { + ok: true, + plan, + receipt: { + requestedAgent: original.requestedAgent, + baseAgent: original.baseAgent, + notices: dedupeNoticesByCode(original.notices), + launchToken: token, + catalogRevision, + telemetry: original.telemetry + } + } + } + + /** Resolve-only entry for the legacy renderer-spawned worktree-create startup + * path: it resolves once from the atomic host view and builds a plan, but + * never admits — that path registers no terminal receipt and has no settle + * seam, so an admitted hold would leak capacity forever. One-release + * compatibility code, deleted with the legacy startupAgent/startupDraft fields. */ + resolveAgentLaunchPlanWithoutAdmission( + args: ResolveAgentLaunchPlanArgs + ): ResolveAgentLaunchPlanResult { + return resolveAgentLaunchPlanWithoutAdmission(args) + } + + /** Pre-create stage of a two-stage worktree launch: resolve once to pin the + * concrete identity + capture the config-only digest, then take a capacity + * hold — all BEFORE git mutation so launch_capacity_exceeded precedes any + * side effect. The resolved argv is intentionally discarded; only the pinned + * identity + digest + reservation survive. */ + prepareReservedAgentLaunch( + args: PrepareReservedAgentLaunchArgs + ): PrepareReservedAgentLaunchResult { + const resolution = args.resolve() + if (!resolution.outcome.ok) { + if ('requestError' in resolution.outcome) { + return { ok: false, requestError: resolution.outcome.requestError } + } + return { ok: false, failure: resolution.outcome.failure } + } + const reservation = this.admissionStore.reserve(args.principal) + if (!reservation.ok) { + return { ok: false, failure: reservation.failure } + } + const launch = resolution.outcome.launch + return { + ok: true, + reservationId: reservation.reservation.reservationId, + requestedAgent: launch.requestedAgent, + baseAgent: launch.baseAgent, + stableInputDigest: launch.admissionGuard.stableInputDigest + } + } + + /** Post-create stage: resolve with authoritative paths and the pinned + * identity, recheck the config-only digest against the pin (a mismatch is a + * config change across the git operation → agent_configuration_changed), then + * convert the held reservation to a token/snapshot inside the coordinator. + * The reservation is released on EVERY post-reserve exit so a failed launch + * never permanently burns capacity. */ + async executeReservedAgentLaunch( + args: ExecuteReservedAgentLaunchArgs + ): Promise { + const nowFn = args.now ?? this.now + const initial = args.resolve() + if (!initial.outcome.ok) { + this.admissionStore.releaseReservation(args.reservationId) + if ('requestError' in initial.outcome) { + return { ok: false, requestError: initial.outcome.requestError } + } + return { ok: false, failure: initial.outcome.failure } + } + const original = initial.outcome.launch + if (original.admissionGuard.stableInputDigest !== args.expectedStableInputDigest) { + this.admissionStore.releaseReservation(args.reservationId) + return { ok: false, failure: { code: 'agent_configuration_changed', ...agentIds(original) } } + } + + const prepFailure = await this.runPreparation(args, original) + if (prepFailure) { + this.admissionStore.releaseReservation(args.reservationId) + return { ok: false, failure: prepFailure } + } + + const originalFingerprint = original.admissionGuard.fingerprint + const result = await this.coordinator.runExclusive(() => + this.admitInsideCoordinator(args.resolve, original, originalFingerprint, () => + this.admissionStore.admitReserved(args.reservationId, { + intent: original.policy.intent, + scope: args.scope, + worktreeId: args.worktreeId ?? null, + fingerprint: originalFingerprint, + snapshot: original.snapshot, + admittedAt: nowFn() + }) + ) + ) + if (result.kind === 'requestError') { + this.admissionStore.releaseReservation(args.reservationId) + return { ok: false, requestError: result.requestError } + } + if (result.kind === 'failure') { + // admitReserved was either never reached (fingerprint mismatch) or failed; + // the hold is still ours to release. + this.admissionStore.releaseReservation(args.reservationId) + return { ok: false, failure: result.failure } + } + // admitReserved consumed the reservation into result.record's token. + return this.finalizeAdmittedLaunch( + args, + original, + result.record.launchToken, + result.catalogRevision + ) + } + + /** Drop a held pre-create reservation when the caller aborts BEFORE + * executeReservedAgentLaunch — e.g. the git worktree creation threw between + * prepare and execute. Frees the capacity a leaked hold would burn forever. */ + releaseReservedAgentLaunch(reservationId: string): void { + this.admissionStore.releaseReservation(reservationId) + } + + /** Move or release the admission reservation once the caller's writer settled. + * Registered retains a private handoff record; failed releases entirely. */ + settleAgentLaunch(launchToken: string, settlement: LaunchSettlement): void { + if (settlement === 'registered') { + const record = this.admissionStore.get(launchToken) + if (record) { + this.retained.set(launchToken, record) + } + } + this.admissionStore.release(launchToken) + } + + /** Private reconciliation lookup; never returned to clients. */ + retainedFor(launchToken: string): AdmittedLaunchRecord | null { + return this.retained.get(launchToken) ?? null + } + + /** Host-only accessor for the admitted-but-unsettled snapshot, so the created- + * path transition can persist it into the private pending-snapshot store in the + * same write as the public pending metadata. Never serialized to clients/logs. */ + pendingSnapshotFor(launchToken: string): AdmittedLaunchRecord['snapshot'] | null { + return this.admissionStore.get(launchToken)?.snapshot ?? null + } + + /** Redacted capacity-recovery rows for the pending-summary surface, filtered to + * the caller's own principal. Keeps the admission store private; the runtime + * drops the launch token before projecting to the client DTO. */ + capacitySummaryFor(principal: AdmissionPrincipal): AdmissionCapacityRow[] { + return this.admissionStore.capacitySummaryFor(principal) + } + + private async runPreparation( + args: ExecuteAgentLaunchArgs, + original: ResolvedAgentLaunch + ): Promise { + const preflightFailure = { code: 'trust_preflight_failed' as const, ...agentIds(original) } + if (args.preflight) { + try { + await args.preflight(original) + } catch { + return preflightFailure + } + } + if (args.prepareEnv) { + try { + await args.prepareEnv(original) + } catch { + return preflightFailure + } + } + return null + } + + /** Coordinator critical section shared by the single-shot and two-stage + * paths: re-take the atomic host view, recheck the relevant-input + * fingerprint, then run the store admit step. No trust/fs/network/provider + * I/O runs here. On the two-stage path a mismatch leaves the reservation + * intact for the caller to release. */ + private admitInsideCoordinator( + resolve: () => ReturnType, + original: ResolvedAgentLaunch, + originalFingerprint: string, + admit: () => ReturnType + ): CriticalResult { + const reResolution = resolve() + if (!reResolution.outcome.ok) { + if ('requestError' in reResolution.outcome) { + return { kind: 'requestError', requestError: reResolution.outcome.requestError } + } + return { kind: 'failure', failure: mapAdmissionMismatch(reResolution.outcome.failure) } + } + if (reResolution.outcome.launch.admissionGuard.fingerprint !== originalFingerprint) { + return { + kind: 'failure', + failure: { code: 'agent_configuration_changed', ...agentIds(original) } + } + } + const admission = admit() + if (!admission.ok) { + return { kind: 'failure', failure: admission.failure } + } + return { + kind: 'admitted', + record: admission.record, + catalogRevision: reResolution.catalogRevision + } + } +} diff --git a/src/main/agent-launch/agent-launch-fingerprint.ts b/src/main/agent-launch/agent-launch-fingerprint.ts new file mode 100644 index 00000000000..abad386c614 --- /dev/null +++ b/src/main/agent-launch/agent-launch-fingerprint.ts @@ -0,0 +1,65 @@ +// Host-private admission fingerprint: a sha256 over only the inputs that could +// change THIS launch. It is never logged, serialized, or returned to a client; +// the admission coordinator (U3) recomputes it under a lock to detect a relevant +// mutation between resolution and commit. Managed-provider inputs land in U3 — +// the `managedProvider` slot is reserved so its shape stays stable. + +import { createHash } from 'node:crypto' +import type { BuiltInTuiAgent, TuiAgent } from '../../shared/types' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' + +export type AdmissionFingerprintBasis = 'explicit' | 'default' | 'snapshot' + +export type AdmissionFingerprintInputs = { + basis: AdmissionFingerprintBasis + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent + mode: 'built-in' | 'custom' | 'safe-fallback' + /** Normalized definition digest (custom) or replay-policy digest (snapshot). */ + definitionDigest: string + baseEnabled: boolean + /** Applicable built-in command config (prefix override + default args) digest. */ + builtInCommandConfig: string + variableValues: { repoPath: string | null; worktreePath: string | null } + /** Authenticated remote-env authorization for this launch (full/withheld/none). */ + remoteEnvAuthorization: string + /** Reserved for U3 managed-provider selection/defaults; empty until then. */ + managedProvider: string + target: { + platform: NodeJS.Platform + execution: 'native' | 'wsl' + shell: AgentStartupShell + isRemote: boolean + executionHostId: AgentLaunchExecutionHostId + homePath: string | null + } + /** Transport-confidentiality capability, when known to the resolver. */ + transportConfidential: boolean | null +} + +/** Deterministic canonical JSON: object keys are emitted in sorted order so the + * digest is stable across key-insertion order. */ +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null' + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + const record = value as Record + const keys = Object.keys(record).sort() + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`).join(',')}}` +} + +/** Compute the host-private admission fingerprint. Never log or serialize the + * return value or its source inputs. */ +export function computeAdmissionFingerprint(inputs: AdmissionFingerprintInputs): string { + return createHash('sha256').update(canonicalize(inputs)).digest('hex') +} + +/** Stable digest of an object subset used inside the fingerprint (definition, + * built-in command config). Keeps raw values out of the exposed structure. */ +export function digestObject(value: unknown): string { + return createHash('sha256').update(canonicalize(value)).digest('hex') +} diff --git a/src/main/agent-launch/agent-launch-host-state.test.ts b/src/main/agent-launch/agent-launch-host-state.test.ts new file mode 100644 index 00000000000..75babc0b7ea --- /dev/null +++ b/src/main/agent-launch/agent-launch-host-state.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from 'vitest' +import { + deriveAgentLaunchHostState, + defaultTransportConfidentiality, + describeSpawnExecutionHost, + detectionUnavailable, + executionHostIdForDescriptor, + isRemoteForDescriptor, + platformForDescriptor, + resolveLocalTargetHomePath, + toStockBaseAgentSet, + type AgentLaunchHostDescriptor, + type AgentLaunchHostStateDeps +} from './agent-launch-host-state' +import type { GlobalSettings } from '../../shared/types' + +function makeDeps(overrides: Partial = {}): AgentLaunchHostStateDeps { + return { + getSettings: () => ({}) as GlobalSettings, + getCatalogRevision: () => 3, + detectStockBaseAgents: async () => ['claude', 'codex'], + resolveTargetHomePath: async () => '/home/dev', + ...overrides + } +} + +describe('executionHostIdForDescriptor', () => { + it('maps each surface to its stable host id', () => { + expect(executionHostIdForDescriptor({ kind: 'local', platform: 'darwin' })).toBe('local') + expect(executionHostIdForDescriptor({ kind: 'wsl', distro: 'Ubuntu 22.04' })).toBe( + 'wsl:Ubuntu%2022.04' + ) + expect( + executionHostIdForDescriptor({ kind: 'ssh', connectionId: 'my host', platform: 'linux' }) + ).toBe('ssh:my%20host') + expect( + executionHostIdForDescriptor({ kind: 'runtime', environmentId: 'env/1', platform: 'linux' }) + ).toBe('runtime:env%2F1') + }) +}) + +describe('platformForDescriptor / isRemoteForDescriptor', () => { + it('forces linux for WSL and keeps the named platform otherwise', () => { + expect(platformForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe('linux') + expect(platformForDescriptor({ kind: 'local', platform: 'win32' })).toBe('win32') + expect(platformForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe( + 'linux' + ) + }) + + it('treats SSH and default runtime as remote, local and WSL as local', () => { + expect(isRemoteForDescriptor({ kind: 'local', platform: 'darwin' })).toBe(false) + expect(isRemoteForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe(false) + expect(isRemoteForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe(true) + expect(isRemoteForDescriptor({ kind: 'runtime', environmentId: 'e', platform: 'linux' })).toBe( + true + ) + expect( + isRemoteForDescriptor({ + kind: 'runtime', + environmentId: 'e', + platform: 'linux', + isRemote: false + }) + ).toBe(false) + }) +}) + +describe('defaultTransportConfidentiality', () => { + it('is undefined same-host, true for SSH, false for an unproven runtime channel', () => { + expect(defaultTransportConfidentiality({ kind: 'local', platform: 'darwin' })).toBeUndefined() + expect(defaultTransportConfidentiality({ kind: 'wsl', distro: 'Ubuntu' })).toBeUndefined() + expect( + defaultTransportConfidentiality({ kind: 'ssh', connectionId: 'h', platform: 'linux' }) + ).toBe(true) + expect( + defaultTransportConfidentiality({ kind: 'runtime', environmentId: 'e', platform: 'linux' }) + ).toBe(false) + }) +}) + +describe('toStockBaseAgentSet', () => { + it('preserves the unknown/known-none distinction and filters to built-ins', () => { + expect(toStockBaseAgentSet(null)).toBeNull() + expect(toStockBaseAgentSet(undefined)).toBeNull() + const none = toStockBaseAgentSet([]) + expect(none).not.toBeNull() + expect(none!.size).toBe(0) + const some = toStockBaseAgentSet(['claude', 'not-an-agent', 'codex']) + expect([...some!].sort()).toEqual(['claude', 'codex']) + }) +}) + +describe('deriveAgentLaunchHostState', () => { + it('derives a full local target with detection and home', async () => { + const state = await deriveAgentLaunchHostState( + makeDeps(), + { kind: 'local', platform: 'darwin' }, + { repoPath: '/repo', worktreePath: '/repo/wt' } + ) + expect(state.target.platform).toBe('darwin') + expect(state.target.isRemote).toBe(false) + expect(state.target.executionHostId).toBe('local') + expect(state.target.targetHomePath).toBe('/home/dev') + expect([...state.target.detectedStockBaseAgents!].sort()).toEqual(['claude', 'codex']) + // Same-host: confidentiality is omitted (undefined), not false. + expect('transportConfidentialityAvailable' in state.target).toBe(false) + expect(state.variables).toEqual({ repoPath: '/repo', worktreePath: '/repo/wt' }) + expect(state.getCatalogRevision()).toBe(3) + }) + + it('carries an SSH target with confidential transport and derived host id', async () => { + const state = await deriveAgentLaunchHostState( + makeDeps({ resolveTargetHomePath: async () => '/home/remote' }), + { kind: 'ssh', connectionId: 'box-1', platform: 'linux', shell: 'posix' }, + {} + ) + expect(state.target.isRemote).toBe(true) + expect(state.target.executionHostId).toBe('ssh:box-1') + expect(state.target.shell).toBe('posix') + expect(state.target.targetHomePath).toBe('/home/remote') + expect(state.target.transportConfidentialityAvailable).toBe(true) + }) + + it('derives a WSL target as local linux with a wsl host id', async () => { + const state = await deriveAgentLaunchHostState( + makeDeps({ resolveTargetHomePath: async () => null }), + { kind: 'wsl', distro: 'Ubuntu' }, + { repoPath: '/mnt/c/repo' } + ) + expect(state.target.platform).toBe('linux') + expect(state.target.isRemote).toBe(false) + expect(state.target.executionHostId).toBe('wsl:Ubuntu') + // Home unknown -> null so the resolver fails missing_target_home for ~ prefixes. + expect(state.target.targetHomePath).toBeNull() + expect('transportConfidentialityAvailable' in state.target).toBe(false) + }) + + it('fails closed on a runtime channel: remote, plaintext-conservative confidentiality', async () => { + const state = await deriveAgentLaunchHostState( + makeDeps(), + { kind: 'runtime', environmentId: 'sandbox-9', platform: 'linux' }, + {} + ) + expect(state.target.isRemote).toBe(true) + expect(state.target.executionHostId).toBe('runtime:sandbox-9') + expect(state.target.transportConfidentialityAvailable).toBe(false) + }) + + it('honors an injected confidentiality override for an identified binding', async () => { + const state = await deriveAgentLaunchHostState( + makeDeps({ resolveTransportConfidentiality: () => true }), + { kind: 'runtime', environmentId: 'sandbox-9', platform: 'linux' }, + {} + ) + expect(state.target.transportConfidentialityAvailable).toBe(true) + }) + + it('passes honest unknowns through when detection and home are unavailable', async () => { + const state = await deriveAgentLaunchHostState( + makeDeps({ + detectStockBaseAgents: detectionUnavailable, + resolveTargetHomePath: async () => null + }), + { kind: 'ssh', connectionId: 'box-1', platform: 'linux' }, + {} + ) + expect(state.target.detectedStockBaseAgents).toBeNull() + expect(state.target.targetHomePath).toBeNull() + }) + + it('normalizes missing variables to null', async () => { + const state = await deriveAgentLaunchHostState( + makeDeps(), + { kind: 'local', platform: 'linux' }, + {} + ) + expect(state.variables).toEqual({ repoPath: null, worktreePath: null }) + }) + + it('runs the async host reads exactly once', async () => { + const detect = vi.fn(async () => ['claude']) + const home = vi.fn(async () => '/home/dev') + await deriveAgentLaunchHostState( + makeDeps({ detectStockBaseAgents: detect, resolveTargetHomePath: home }), + { kind: 'local', platform: 'linux' }, + {} + ) + expect(detect).toHaveBeenCalledTimes(1) + expect(home).toHaveBeenCalledTimes(1) + }) +}) + +describe('describeSpawnExecutionHost', () => { + it('describes a local target with this machine platform', () => { + const descriptor = describeSpawnExecutionHost({ connectionId: null, cwd: '/repo' }) + expect(descriptor.kind).toBe('local') + expect(descriptor).toMatchObject({ kind: 'local', platform: process.platform }) + }) + + it('describes an SSH target and infers linux from a POSIX cwd', () => { + const descriptor = describeSpawnExecutionHost({ + connectionId: 'host-1', + cwd: '/home/user/repo' + }) + expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' }) + }) + + it('infers win32 for an SSH target with a Windows-shaped cwd', () => { + const descriptor = describeSpawnExecutionHost({ + connectionId: 'host-1', + cwd: 'C:\\Users\\me\\repo' + }) + expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'win32' }) + }) + + it('defaults an SSH target to linux when the cwd is unknown', () => { + const descriptor = describeSpawnExecutionHost({ connectionId: 'host-1' }) + expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' }) + }) +}) + +describe('resolveLocalTargetHomePath', () => { + it('returns a home dir for local and null for every other surface', async () => { + const local: AgentLaunchHostDescriptor = { kind: 'local', platform: process.platform } + await expect(resolveLocalTargetHomePath(local)).resolves.toEqual(expect.any(String)) + await expect( + resolveLocalTargetHomePath({ kind: 'ssh', connectionId: 'h', platform: 'linux' }) + ).resolves.toBeNull() + await expect(resolveLocalTargetHomePath({ kind: 'wsl', distro: 'Ubuntu' })).resolves.toBeNull() + }) +}) diff --git a/src/main/agent-launch/agent-launch-host-state.ts b/src/main/agent-launch/agent-launch-host-state.ts new file mode 100644 index 00000000000..9fcc0d0418b --- /dev/null +++ b/src/main/agent-launch/agent-launch-host-state.ts @@ -0,0 +1,221 @@ +// Main-side host-state provider for agent launches (U3). Given a spawn surface's +// execution descriptor (local, WSL, SSH, or runtime), it derives the fixed +// AgentLaunchSpawnTarget the resolver consumes: platform, shell, isRemote, the +// stable execution-host id, the target home path for `~` expansion, the stock +// detection snapshot, and the cross-host transport-confidentiality signal. +// +// The provider NEVER fabricates a value it cannot observe. Detection is null when +// unavailable (never an empty set standing in for "unknown"); the target home is +// null when the host has not resolved it (the resolver then fails +// missing_target_home only for `~`-prefixed values); confidentiality is undefined +// for same-host launches and conservatively false for a cross-host channel whose +// binding cannot be proven. Detection/home resolution are injected async host +// reads so this module stays electron-free and unit-testable. + +import { homedir } from 'node:os' +import type { BuiltInTuiAgent, GlobalSettings } from '../../shared/types' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../shared/execution-host' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import type { AgentLaunchSpawnTarget } from './agent-launch-spawn' + +/** The execution surface a launch targets. isRemote/platform/executionHostId are + * derived from this shape; nothing is copied from a client payload. */ +export type AgentLaunchHostDescriptor = + | { kind: 'local'; platform: NodeJS.Platform; shell?: AgentStartupShell } + | { kind: 'wsl'; distro: string; shell?: AgentStartupShell } + | { kind: 'ssh'; connectionId: string; platform: NodeJS.Platform; shell?: AgentStartupShell } + | { + kind: 'runtime' + environmentId: string + platform: NodeJS.Platform + /** Runtime environments are separate hosts by default; a caller that knows + * the env is in-process may set false. */ + isRemote?: boolean + shell?: AgentStartupShell + } + +/** The stable execution-host id, reusing the shared SSH/runtime encoders and this + * feature's `wsl:${distro}` variant (the shared ExecutionHostId grammar has no + * WSL arm). */ +export function executionHostIdForDescriptor( + descriptor: AgentLaunchHostDescriptor +): AgentLaunchExecutionHostId { + switch (descriptor.kind) { + case 'local': + return 'local' + case 'wsl': + return `wsl:${encodeURIComponent(descriptor.distro)}` + case 'ssh': + return toSshExecutionHostId(descriptor.connectionId) + case 'runtime': + return toRuntimeExecutionHostId(descriptor.environmentId) + } +} + +/** WSL always executes a Linux userland; every other descriptor names its own + * terminal-target platform. */ +export function platformForDescriptor(descriptor: AgentLaunchHostDescriptor): NodeJS.Platform { + return descriptor.kind === 'wsl' ? 'linux' : descriptor.platform +} + +/** SSH and (by default) runtime are separate hosts; local and WSL execute on this + * machine, matching repoIsRemote (connectionId-only) semantics. */ +export function isRemoteForDescriptor(descriptor: AgentLaunchHostDescriptor): boolean { + if (descriptor.kind === 'ssh') { + return true + } + if (descriptor.kind === 'runtime') { + return descriptor.isRemote ?? true + } + return false +} + +/** Conservative confidentiality: same-host launches carry no cross-host transport + * (undefined). SSH is authenticated and confidential (true). A runtime channel's + * binding cannot be proven from host state alone, so env-bearing launches into it + * fail closed (false) unless a caller overrides with an identified binding. */ +export function defaultTransportConfidentiality( + descriptor: AgentLaunchHostDescriptor +): boolean | undefined { + if (descriptor.kind === 'local' || descriptor.kind === 'wsl') { + return undefined + } + if (descriptor.kind === 'ssh') { + return true + } + return false +} + +/** Filter a raw detected-agent list to the stock base agents the resolver gates + * on. null/undefined input means detection is unavailable and is preserved as + * null (unknown); an empty array is "detection ran, nothing installed" and stays + * an empty set — the two must not collapse. */ +export function toStockBaseAgentSet( + detected: readonly string[] | null | undefined +): ReadonlySet | null { + if (detected === null || detected === undefined) { + return null + } + const set = new Set() + for (const id of detected) { + if (isBuiltInTuiAgent(id)) { + set.add(id) + } + } + return set +} + +/** Map a terminal spawn's connection + cwd to its execution-host descriptor. + * An SSH target (connectionId present) infers platform from the remote cwd's + * path shape — the same heuristic the runtime uses — because the IPC boundary + * has no synchronous remote-platform probe; its home/detection stay honest + * unknowns until a caller that can probe supplies them. A local target uses + * this machine's platform and Windows shell family. WSL/runtime hosts are + * described by callers that know the distro/env id. */ +export function describeSpawnExecutionHost(args: { + connectionId?: string | null + cwd?: string | null + terminalWindowsShell?: string | null +}): AgentLaunchHostDescriptor { + if (args.connectionId) { + return { + kind: 'ssh', + connectionId: args.connectionId, + platform: args.cwd && isWindowsAbsolutePathLike(args.cwd) ? 'win32' : 'linux' + } + } + const shell = resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: args.terminalWindowsShell + }) + return { + kind: 'local', + platform: process.platform, + ...(shell ? { shell } : {}) + } +} + +export type AgentLaunchHostStateDeps = { + getSettings: () => GlobalSettings + getCatalogRevision: () => number + /** Detect stock base agents on the target's baseline PATH. Return null when + * detection is genuinely unavailable — never an empty list to mean unknown. */ + detectStockBaseAgents: ( + descriptor: AgentLaunchHostDescriptor + ) => Promise + /** Resolve the target host's home dir for `~` expansion, or null when the host + * has not resolved it (SSH before resolveHome, an unknown WSL distro $HOME). */ + resolveTargetHomePath: (descriptor: AgentLaunchHostDescriptor) => Promise + /** Override the default confidentiality derivation when a cross-host channel's + * binding is identifiable (e.g. a runtime env reached over SSH). */ + resolveTransportConfidentiality?: (descriptor: AgentLaunchHostDescriptor) => boolean | undefined +} + +/** The surface-specific host state a launch resolves against: the live settings + * accessors and the fixed target/variables snapshot. Settings and the normalized + * catalog are read live per resolution by resolveAgentLaunchSpawn; the target and + * variables are the immutable per-surface derivation captured here. */ +export type AgentLaunchHostState = { + getSettings: () => GlobalSettings + getCatalogRevision: () => number + target: AgentLaunchSpawnTarget + variables: { repoPath: string | null; worktreePath: string | null } +} + +/** Derive the per-surface host state for a launch. Performs the async host reads + * (detection, target home) once, up front, so the boundary's synchronous + * re-resolution inside the admission coordinator only re-reads settings. */ +export async function deriveAgentLaunchHostState( + deps: AgentLaunchHostStateDeps, + descriptor: AgentLaunchHostDescriptor, + variables: { repoPath?: string | null; worktreePath?: string | null } +): Promise { + const platform = platformForDescriptor(descriptor) + const isRemote = isRemoteForDescriptor(descriptor) + const executionHostId = executionHostIdForDescriptor(descriptor) + const [detected, targetHomePath] = await Promise.all([ + deps.detectStockBaseAgents(descriptor), + deps.resolveTargetHomePath(descriptor) + ]) + const confidentiality = (deps.resolveTransportConfidentiality ?? defaultTransportConfidentiality)( + descriptor + ) + + const target: AgentLaunchSpawnTarget = { + platform, + ...(descriptor.shell ? { shell: descriptor.shell } : {}), + isRemote, + executionHostId, + targetHomePath: targetHomePath ?? null, + detectedStockBaseAgents: toStockBaseAgentSet(detected), + ...(confidentiality !== undefined ? { transportConfidentialityAvailable: confidentiality } : {}) + } + + return { + getSettings: deps.getSettings, + getCatalogRevision: deps.getCatalogRevision, + target, + variables: { + repoPath: variables.repoPath ?? null, + worktreePath: variables.worktreePath ?? null + } + } +} + +/** Default detection resolver: detection unavailable (unknown). Callers that can + * run real stock detection inject their own; the honest default never claims an + * agent is missing. */ +export const detectionUnavailable = async (): Promise => null + +/** Default home resolver: this machine's home dir for a local target, null + * otherwise (a remote/WSL home must be resolved by the host that owns it). */ +export async function resolveLocalTargetHomePath( + descriptor: AgentLaunchHostDescriptor +): Promise { + return descriptor.kind === 'local' ? homedir() : null +} diff --git a/src/main/agent-launch/agent-launch-import-boundary.test.ts b/src/main/agent-launch/agent-launch-import-boundary.test.ts new file mode 100644 index 00000000000..60bd1f1fae9 --- /dev/null +++ b/src/main/agent-launch/agent-launch-import-boundary.test.ts @@ -0,0 +1,65 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +// The host resolver and its host-only contract must never reach a renderer, +// mobile, or web bundle. Type location alone is not a security boundary; this +// grep is the enforcement (allowed importers: src/main/**, src/shared/**, tests). +const REPO_ROOT = join(__dirname, '..', '..', '..') + +const CLIENT_ROOTS = ['src/renderer', 'mobile/src', 'src/web'] + +const FORBIDDEN_IMPORTS = [ + 'agent-launch-host-contract', + 'agent-launch/resolve-agent-launch', + 'agent-launch/resolve-agent-command', + 'agent-launch/resolve-agent-selection', + 'agent-launch/compose-agent-launch-env' +] + +const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']) + +function collectSourceFiles(dir: string, out: string[]): void { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const entry of entries) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + if (entry === 'node_modules' || entry === '.git') { + continue + } + collectSourceFiles(full, out) + continue + } + const dot = entry.lastIndexOf('.') + if (dot >= 0 && SOURCE_EXT.has(entry.slice(dot))) { + out.push(full) + } + } +} + +describe('agent-launch host-boundary imports', () => { + it('no renderer/mobile/web source imports the host resolver or host contract', () => { + const files: string[] = [] + for (const root of CLIENT_ROOTS) { + collectSourceFiles(join(REPO_ROOT, root), files) + } + // Guard against silently scanning nothing (e.g. a moved directory). + expect(files.length).toBeGreaterThan(0) + + const offenders: string[] = [] + for (const file of files) { + const text = readFileSync(file, 'utf8') + for (const forbidden of FORBIDDEN_IMPORTS) { + if (text.includes(forbidden)) { + offenders.push(`${file} -> ${forbidden}`) + } + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/src/main/agent-launch/agent-launch-legacy-replay.test.ts b/src/main/agent-launch/agent-launch-legacy-replay.test.ts new file mode 100644 index 00000000000..2d95cb8319e --- /dev/null +++ b/src/main/agent-launch/agent-launch-legacy-replay.test.ts @@ -0,0 +1,125 @@ +// U5: opaque one-release legacy-config replay. Proves provider resume flags are +// appended exactly once to the one-shot command (never the durable config), that +// Orca attribution env is stripped, and that every failure mode fails closed to +// invalid_launch_snapshot without a partial replay. +import { describe, expect, it } from 'vitest' +import { + RESUMABLE_TUI_AGENTS, + getAgentResumeArgv, + providerSessionKeyForResumableBase +} from '../../shared/agent-session-resume' +import { buildLegacyResumeReplay } from './agent-launch-legacy-replay' + +function replay(overrides: Partial[0]> = {}) { + return buildLegacyResumeReplay({ + legacyLaunchConfig: { agentCommand: 'claude', agentArgs: '--model opus', agentEnv: {} }, + requestedAgent: 'claude', + baseAgent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + shell: 'posix', + recordedConnectionId: null, + currentConnectionId: null, + ...overrides + }) +} + +describe('buildLegacyResumeReplay', () => { + it('appends the provider resume flags to the one-shot command only', () => { + const result = replay() + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.launchCommand).toContain('--resume') + expect(result.launchCommand).toContain('sess-1') + // Durable config keeps the base args only, so a fresh relaunch never re-resumes. + expect(result.launchConfig.agentArgs).toBe('--model opus') + expect(result.launchConfig.agentArgs).not.toContain('--resume') + expect(result.launchConfig.agentCommand).toBe('claude') + }) + + it('appends resume argv once for every resumable base', () => { + for (const base of RESUMABLE_TUI_AGENTS) { + const key = providerSessionKeyForResumableBase(base) + const providerSession = { key, id: 'sess-9' } as const + const result = replay({ baseAgent: base, requestedAgent: base, providerSession }) + expect(result.ok, `base ${base}`).toBe(true) + if (!result.ok) { + continue + } + const resumeArgv = getAgentResumeArgv(base, providerSession) + expect(resumeArgv).not.toBeNull() + // The final flag/value pair appears exactly once in the one-shot command. + const lastFlag = resumeArgv?.at(-2) + if (lastFlag) { + const occurrences = result.launchCommand.split(lastFlag).length - 1 + expect(occurrences, `base ${base} flag ${lastFlag}`).toBe(1) + } + } + }) + + it('strips Orca attribution and tmux identity env before replay', () => { + const result = replay({ + legacyLaunchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { + FOO: 'bar', + ORCA_PANE_KEY: 'p', + ORCA_AGENT_LAUNCH_TOKEN: 't', + TMUX: 'x', + TMUX_PANE: '%1' + } + } + }) + expect(result.ok && result.launchConfig.agentEnv).toEqual({ FOO: 'bar' }) + }) + + it('strips captured Agent Teams identity and the shim PATH prefix from the durable config', () => { + const result = replay({ + legacyLaunchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + TERM: 'screen-256color', + TMUX: 'x', + TMUX_PANE: '%1', + ORCA_AGENT_TEAMS_TOKEN: 'stale-token', + ORCA_AGENT_TEAMS_SHIM_DIR: '/home/me/.orca/teams-bin', + PATH: '/home/me/.orca/teams-bin:/usr/bin', + MY_TOKEN: 'keep' + } + } + }) + // Generated team identity and stale token are gone; the user PATH tail and + // custom key survive so the downstream path can regenerate a fresh team plan. + expect(result.ok && result.launchConfig.agentEnv).toEqual({ + PATH: '/usr/bin', + MY_TOKEN: 'keep' + }) + }) + + it('fails closed when the recorded owner differs from the current owner', () => { + expect(replay({ recordedConnectionId: 'ssh:a', currentConnectionId: 'ssh:b' }).ok).toBe(false) + }) + + it('fails closed on an empty command', () => { + expect( + replay({ legacyLaunchConfig: { agentCommand: '', agentArgs: '', agentEnv: {} } }).ok + ).toBe(false) + expect(replay({ legacyLaunchConfig: { agentArgs: '', agentEnv: {} } }).ok).toBe(false) + }) + + it('fails closed on a control character in the command', () => { + expect( + replay({ legacyLaunchConfig: { agentCommand: 'claude\n rm', agentArgs: '', agentEnv: {} } }) + .ok + ).toBe(false) + }) + + it('fails closed when the session key type does not match the base', () => { + // claude is session_id-keyed; a conversation_id session cannot resume it. + expect(replay({ providerSession: { key: 'conversation_id', id: 'x' } }).ok).toBe(false) + }) +}) diff --git a/src/main/agent-launch/agent-launch-legacy-replay.ts b/src/main/agent-launch/agent-launch-legacy-replay.ts new file mode 100644 index 00000000000..48ab8f58b13 --- /dev/null +++ b/src/main/agent-launch/agent-launch-legacy-replay.ts @@ -0,0 +1,106 @@ +// Opaque one-release legacy-config replay (U5, plan §570-575). A pre-U5 sleeping +// record persisted a pre-quoted `agentCommand` that "cannot always be split +// safely", so it is replayed verbatim as an opaque string rather than re-parsed +// into the v1 snapshot's structured argv. This path is desktop/host-initiated +// only: the renderer surrenders the legacy config over trusted IPC exactly once, +// the host validates it, the ingest layer persists it into the private record +// store (owns it thereafter), and it never rides untrusted runtime/mobile RPC — +// so a mobile/paired legacy resume falls through to invalid_launch_snapshot. +// +// Unlike the resolver, this does NOT produce a ResolvedAgentLaunch: the opaque +// command bypasses structured resolution and feeds the pre-U5 launchCommand / +// launchConfig fields directly. The durable config stays base-only (no resume +// flags) so a fresh relaunch never re-resumes a stale session; the provider +// resume flags land only in the one-shot launchCommand. + +import type { TuiAgent } from '../../shared/types' +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata, + type ResumableTuiAgent, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import { validateCustomAgentEnv } from '../../shared/custom-tui-agent-fields' +import { quoteStartupArg, type AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import { stripLegacyReplayEnv } from './agent-launch-legacy-teams-env' + +export type LegacyReplayInput = { + legacyLaunchConfig: SleepingAgentLaunchConfig + requestedAgent: TuiAgent + baseAgent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + shell: AgentStartupShell + /** Recorded execution owner of the sleeping pane. */ + recordedConnectionId: string | null + /** Current spawn's execution owner; provenance requires it to equal the + * recorded owner (never inferred from focused repo/client — plan §573). */ + currentConnectionId: string | null +} + +export type LegacyReplayResult = + | { + ok: true + launchCommand: string + launchConfig: SleepingAgentLaunchConfig + requestedAgent: TuiAgent + baseAgent: ResumableTuiAgent + } + | { ok: false; failure: { code: 'invalid_launch_snapshot' } } + +const INVALID: LegacyReplayResult = { ok: false, failure: { code: 'invalid_launch_snapshot' } } + +// Control chars that would corrupt an opaque shell command. Mirrors the command +// override guard; the pre-quoted command text is otherwise passed through. +// eslint-disable-next-line no-control-regex -- rejecting control chars is the point +const COMMAND_CONTROL_RE = /[\0\r\n\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/ + +/** Assemble an opaque legacy resume launch. Fails closed (`invalid_launch_snapshot`, + * leaving the source record untouched) on owner mismatch, an unresumable base, an + * empty/control-char command, or invalid surviving env — never a partial replay. */ +export function buildLegacyResumeReplay(input: LegacyReplayInput): LegacyReplayResult { + // Provenance: the recorded execution owner must match the current spawn's owner. + // Missing/conflicting owner evidence fails closed rather than inferring a target. + if ((input.recordedConnectionId ?? null) !== (input.currentConnectionId ?? null)) { + return INVALID + } + + const { agentCommand, agentArgs } = input.legacyLaunchConfig + const command = agentCommand?.trim() ?? '' + if (!command || COMMAND_CONTROL_RE.test(command)) { + return INVALID + } + const trimmedArgs = agentArgs.trim() + if (trimmedArgs && COMMAND_CONTROL_RE.test(trimmedArgs)) { + return INVALID + } + + // Strip Orca attribution + generated Agent Teams keys (and the proven shim PATH + // prefix) first, then validate the surviving user env as a whole; any invalid + // key/value invalidates the entire config (never partial). The downstream + // launch path regenerates a fresh team plan for a captured team config. + const cleanedEnv = stripLegacyReplayEnv(input.legacyLaunchConfig.agentEnv, input.shell) + if (validateCustomAgentEnv(cleanedEnv).length > 0) { + return INVALID + } + + // Provider resume flags append to the one-shot command only. An unresumable + // base or a session whose key type does not match the base cannot resume. + const resumeArgv = getAgentResumeArgv(input.baseAgent, input.providerSession) + if (!resumeArgv) { + return INVALID + } + const resumeSuffix = resumeArgv + .slice(1) + .map((element) => quoteStartupArg(element, input.shell)) + .join(' ') + + const launchCommand = [command, trimmedArgs, resumeSuffix].filter(Boolean).join(' ') + return { + ok: true, + launchCommand, + // Durable config: base command/args only (no resume flags), cleaned env. + launchConfig: { agentCommand: command, agentArgs: trimmedArgs, agentEnv: cleanedEnv }, + requestedAgent: input.requestedAgent, + baseAgent: input.baseAgent + } +} diff --git a/src/main/agent-launch/agent-launch-legacy-teams-env.test.ts b/src/main/agent-launch/agent-launch-legacy-teams-env.test.ts new file mode 100644 index 00000000000..5920d8245a3 --- /dev/null +++ b/src/main/agent-launch/agent-launch-legacy-teams-env.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + isCapturedAgentTeamsConfig, + pathDelimiterForShell, + stripLegacyReplayEnv +} from './agent-launch-legacy-teams-env' + +// A captured Claude Agent Teams leader env (see createLaunchEnv), plus a user's +// own custom key that must survive replay. +function capturedTeamEnv(overrides: Record = {}): Record { + return { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + PATH: '/home/me/.orca/claude-agent-teams-bin:/usr/local/bin:/usr/bin', + TMUX: '/tmp/orca-claude-agent-teams/team-abc,0,1', + TMUX_PANE: '%1', + TERM: 'screen-256color', + COLORTERM: 'truecolor', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-abc', + ORCA_AGENT_TEAMS_TOKEN: 'secret-token', + ORCA_AGENT_TEAMS_LEADER_PANE: '%1', + ORCA_AGENT_TEAMS_SHIM_DIR: '/home/me/.orca/claude-agent-teams-bin', + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca', + ORCA_PAIRING_CODE: 'pair-123', + ORCA_ENVIRONMENT: 'prod', + ORCA_PANE_KEY: 'pane-key', + MY_CUSTOM_TOKEN: 'keep-me', + ...overrides + } +} + +describe('pathDelimiterForShell', () => { + it('uses : on posix and ; on Windows shells', () => { + expect(pathDelimiterForShell('posix')).toBe(':') + expect(pathDelimiterForShell('powershell')).toBe(';') + expect(pathDelimiterForShell('cmd')).toBe(';') + }) +}) + +describe('isCapturedAgentTeamsConfig', () => { + it('detects a team config by its generated markers', () => { + expect(isCapturedAgentTeamsConfig({ ORCA_AGENT_TEAMS_TEAM_ID: 'x' })).toBe(true) + expect(isCapturedAgentTeamsConfig({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' })).toBe(true) + }) + + it('does not flag an ordinary user env', () => { + expect(isCapturedAgentTeamsConfig({ PATH: '/usr/bin', TERM: 'xterm', FOO: 'bar' })).toBe(false) + }) +}) + +describe('stripLegacyReplayEnv — non-team config', () => { + it('preserves user PATH and TERM, stripping only orca attribution + tmux', () => { + const cleaned = stripLegacyReplayEnv( + { + PATH: '/usr/local/bin:/usr/bin', + TERM: 'xterm-256color', + TMUX: 'x', + TMUX_PANE: '%9', + ORCA_PANE_KEY: 'pane', + MY_TOKEN: 'keep' + }, + 'posix' + ) + expect(cleaned).toEqual({ + PATH: '/usr/local/bin:/usr/bin', + TERM: 'xterm-256color', + MY_TOKEN: 'keep' + }) + }) +}) + +describe('stripLegacyReplayEnv — captured team config', () => { + it('drops every generated team/auth/TMUX/TERM/pairing key and keeps user env', () => { + const cleaned = stripLegacyReplayEnv(capturedTeamEnv(), 'posix') + expect(cleaned.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined() + expect(cleaned.TMUX).toBeUndefined() + expect(cleaned.TMUX_PANE).toBeUndefined() + expect(cleaned.TERM).toBeUndefined() + expect(cleaned.COLORTERM).toBeUndefined() + expect(cleaned.ORCA_AGENT_TEAMS_TOKEN).toBeUndefined() + expect(cleaned.ORCA_AGENT_TEAMS_SHIM_DIR).toBeUndefined() + expect(cleaned.ORCA_PAIRING_CODE).toBeUndefined() + expect(cleaned.ORCA_ENVIRONMENT).toBeUndefined() + expect(cleaned.ORCA_PANE_KEY).toBeUndefined() + // The user's own custom key survives. + expect(cleaned.MY_CUSTOM_TOKEN).toBe('keep-me') + }) + + it('removes the proven shim prefix from PATH, preserving the user tail', () => { + const cleaned = stripLegacyReplayEnv(capturedTeamEnv(), 'posix') + expect(cleaned.PATH).toBe('/usr/local/bin:/usr/bin') + }) + + it('quotes the Windows shim prefix with the ; delimiter', () => { + const cleaned = stripLegacyReplayEnv( + capturedTeamEnv({ + PATH: 'C:\\Users\\me\\.orca\\bin;C:\\Windows\\System32', + ORCA_AGENT_TEAMS_SHIM_DIR: 'C:\\Users\\me\\.orca\\bin' + }), + 'powershell' + ) + expect(cleaned.PATH).toBe('C:\\Windows\\System32') + }) + + it('drops PATH when the shim dir cannot be proven (ambiguous)', () => { + const withoutShimDir = capturedTeamEnv() + delete withoutShimDir.ORCA_AGENT_TEAMS_SHIM_DIR + // Still a team config via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, but the shim + // segment is now unprovable → drop rather than replay a poisoned PATH. + const cleaned = stripLegacyReplayEnv(withoutShimDir, 'posix') + expect(cleaned.PATH).toBeUndefined() + }) + + it('drops PATH when its first segment is not the captured shim dir', () => { + const cleaned = stripLegacyReplayEnv( + capturedTeamEnv({ PATH: '/usr/local/bin:/home/me/.orca/claude-agent-teams-bin' }), + 'posix' + ) + expect(cleaned.PATH).toBeUndefined() + }) + + it('drops PATH entirely when the shim dir is the only segment', () => { + const cleaned = stripLegacyReplayEnv( + capturedTeamEnv({ PATH: '/home/me/.orca/claude-agent-teams-bin' }), + 'posix' + ) + expect(cleaned.PATH).toBeUndefined() + }) +}) diff --git a/src/main/agent-launch/agent-launch-legacy-teams-env.ts b/src/main/agent-launch/agent-launch-legacy-teams-env.ts new file mode 100644 index 00000000000..5533e4f9310 --- /dev/null +++ b/src/main/agent-launch/agent-launch-legacy-teams-env.ts @@ -0,0 +1,90 @@ +// §571 env cleaning for opaque legacy replay. A pre-U5 sleeping record may have +// captured a Claude Agent Teams launch env whose team identity, tmux/TERM state, +// pairing keys, and shim-prefixed PATH were minted per-launch. Replaying those +// verbatim would re-inject a stale team token/shim; the downstream launch path +// regenerates a fresh team plan, so the durable replay config must drop the +// generated keys while preserving a safely separable user PATH tail. +// +// This is deliberately NOT an extension of the shared `stripEphemeralAgentTeamsEnv` +// (claude-agent-teams-service.ts): that function also cleans the FRESH-launch +// durable snapshot (orca-runtime), where a user's own custom TERM/PATH must be +// preserved. Stripping TERM/PATH there would regress custom env. The legacy +// cleaning only engages for a CAPTURED team config and removes the shim PATH +// prefix surgically (proven by the captured shim-dir), so it is safe to apply +// only on the legacy replay path. + +// Generated keys removed only when the config is a captured team launch. TMUX / +// TMUX_PANE are ephemeral for every launch and stripped unconditionally below. +const GENERATED_TEAM_ONLY_KEYS = new Set([ + 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', + 'TERM', + 'COLORTERM' +]) + +// Presence of any of these proves the captured env came from an Agent Teams +// launch; only then do the team-only strips and PATH-shim removal engage. +const TEAM_MARKER_KEY_PREFIX = 'ORCA_AGENT_TEAMS_' +const TEAM_MARKER_KEYS = ['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] + +export function isCapturedAgentTeamsConfig(env: Record): boolean { + return Object.keys(env).some( + (key) => key.startsWith(TEAM_MARKER_KEY_PREFIX) || TEAM_MARKER_KEYS.includes(key) + ) +} + +export function pathDelimiterForShell(shell: 'posix' | 'powershell' | 'cmd'): string { + return shell === 'posix' ? ':' : ';' +} + +/** Remove Orca attribution + (for captured team configs) generated team/auth/ + * TMUX/TERM/pairing keys and the proven shim PATH prefix. Non-team configs keep + * their PATH and TERM untouched. Fails safe on an unprovable shim by dropping the + * whole PATH entry rather than replaying a possibly shim-poisoned one. */ +export function stripLegacyReplayEnv( + env: Record, + shell: 'posix' | 'powershell' | 'cmd' +): Record { + const isTeam = isCapturedAgentTeamsConfig(env) + const shimDir = env.ORCA_AGENT_TEAMS_SHIM_DIR?.trim() || null + const delimiter = pathDelimiterForShell(shell) + const cleaned: Record = {} + for (const [key, value] of Object.entries(env)) { + const lower = key.toLowerCase() + // Orca attribution (pane/hook/token, team ids, pairing, environment) plus the + // tmux pane handle are always regenerated and must never replay. + if (lower.startsWith('orca_') || key === 'TMUX' || key === 'TMUX_PANE') { + continue + } + if (isTeam && GENERATED_TEAM_ONLY_KEYS.has(key)) { + continue + } + if (key === 'PATH' && isTeam) { + const tail = resolveUserPathTail(value, shimDir, delimiter) + if (tail) { + cleaned.PATH = tail + } + continue + } + cleaned[key] = value + } + return cleaned +} + +/** Return the user PATH tail after removing the proven shim prefix, or null when + * the shim cannot be proven (drop the ambiguous PATH rather than guess). The + * shim dir is prepended as the FIRST segment by createLaunchEnv. */ +function resolveUserPathTail( + pathValue: string, + shimDir: string | null, + delimiter: string +): string | null { + if (!shimDir) { + return null + } + const segments = pathValue.split(delimiter) + if (segments[0] !== shimDir) { + return null + } + const tail = segments.slice(1).filter(Boolean) + return tail.length > 0 ? tail.join(delimiter) : null +} diff --git a/src/main/agent-launch/agent-launch-operation-store-host.ts b/src/main/agent-launch/agent-launch-operation-store-host.ts new file mode 100644 index 00000000000..f321465c564 --- /dev/null +++ b/src/main/agent-launch/agent-launch-operation-store-host.ts @@ -0,0 +1,17 @@ +// Host-wide singleton launch-operation store. Paired with the singleton launch +// boundary (agent-launch-boundary-host.ts): the boundary owns admission, this +// owns the durable idempotency ledger + private pending-snapshot attribution. +// One instance per host so retry idempotency and crash reconciliation see every +// creation attempt. Durable persistence (rehydrate on startup) attaches with the +// reconciliation work; the in-memory instance backs the create/retry path. + +import { AgentLaunchOperationStore } from './agent-launch-operation-store' + +let store: AgentLaunchOperationStore | null = null + +export function getHostAgentLaunchOperationStore(): AgentLaunchOperationStore { + if (!store) { + store = new AgentLaunchOperationStore() + } + return store +} diff --git a/src/main/agent-launch/agent-launch-operation-store-persistence.test.ts b/src/main/agent-launch/agent-launch-operation-store-persistence.test.ts new file mode 100644 index 00000000000..d9a4801e829 --- /dev/null +++ b/src/main/agent-launch/agent-launch-operation-store-persistence.test.ts @@ -0,0 +1,165 @@ +import { mkdtempSync, readFileSync, 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 { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' + +// The module imports `safeStorage` at top for its Electron cipher factory; these +// tests inject their own cipher, so a bare stub keeps the import resolvable. +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value, 'utf-8'), + decryptString: (value: Buffer) => value.toString('utf-8') + } +})) + +import type { + AgentLaunchOperationStoreDurableState, + PendingAgentLaunchSnapshot, + SettledAgentLaunchOperation +} from './agent-launch-operation-store' +import { + agentLaunchOperationStorePath, + decodeAgentLaunchOperationStore, + encodeAgentLaunchOperationStore, + loadAgentLaunchOperationStoreState, + writeAgentLaunchOperationStoreState, + type AgentLaunchOperationCipher +} from './agent-launch-operation-store-persistence' + +// XOR-ish reversible transform standing in for safeStorage so the envelope +// round-trip is exercised without an OS keychain, and the on-disk pending bytes +// are verifiably NOT the plaintext. +function reversibleCipher(available: boolean): AgentLaunchOperationCipher { + return { + available: () => available, + encrypt: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decrypt: (ciphertext) => ciphertext.toString('utf-8').replace(/^enc:/, '') + } +} + +const snapshot: AgentLaunchSnapshot = { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: { SECRET_TOKEN: 'do-not-leak' }, + capturedEnvPolicy: 'full', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} + +function pending(token: string): PendingAgentLaunchSnapshot { + return { + operationId: `op-${token}`, + idempotencyKey: `key-${token}`, + scope: 'r1::/wt', + clientMutationId: null, + payloadDigest: `digest-${token}`, + launchToken: token, + intent: 'interactive', + snapshot + } +} + +function settled(operationId: string): SettledAgentLaunchOperation { + return { + operationId, + idempotencyKey: `key-${operationId}`, + scope: 'r1::/wt', + payloadDigest: `digest-${operationId}`, + status: 'launched', + terminalId: 'term-1', + failureId: null, + settledAt: 10 + } +} + +describe('agent-launch operation-store persistence', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'agent-launch-store-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('round-trips both halves through encrypted encode/decode', () => { + const cipher = reversibleCipher(true) + const state: AgentLaunchOperationStoreDurableState = { + pending: [pending('tok-a')], + settled: [settled('op-1')] + } + const decoded = decodeAgentLaunchOperationStore( + encodeAgentLaunchOperationStore(state, cipher), + cipher + ) + expect(decoded.pending).toEqual(state.pending) + expect(decoded.settled).toEqual(state.settled) + }) + + it('encrypts the pending section so the token never appears in cleartext on disk', () => { + const cipher = reversibleCipher(true) + const path = agentLaunchOperationStorePath(dir) + writeAgentLaunchOperationStoreState( + path, + { pending: [pending('super-secret-token')], settled: [] }, + cipher + ) + const bytes = readFileSync(path, 'utf-8') + expect(bytes).not.toContain('super-secret-token') + expect(bytes).not.toContain('do-not-leak') + const reloaded = loadAgentLaunchOperationStoreState(path, cipher) + expect(reloaded.pending[0]?.launchToken).toBe('super-secret-token') + }) + + it('falls back to a hardened plaintext pending section when encryption is unavailable', () => { + const cipher = reversibleCipher(false) + const path = agentLaunchOperationStorePath(dir) + writeAgentLaunchOperationStoreState(path, { pending: [pending('tok-b')], settled: [] }, cipher) + const reloaded = loadAgentLaunchOperationStoreState(path, cipher) + expect(reloaded.pending[0]?.launchToken).toBe('tok-b') + }) + + it('returns empty state for a missing file', () => { + expect( + loadAgentLaunchOperationStoreState(agentLaunchOperationStorePath(dir), reversibleCipher(true)) + ).toEqual({ + pending: [], + settled: [] + }) + }) + + it('keeps the settled ledger but drops pending when the pending section cannot be decrypted', () => { + // Written with an available cipher, reloaded with an unavailable one: the + // encrypted pending cannot be read, but the plaintext ledger survives. + const path = agentLaunchOperationStorePath(dir) + writeAgentLaunchOperationStoreState( + path, + { pending: [pending('tok-c')], settled: [settled('op-2')] }, + reversibleCipher(true) + ) + const reloaded = loadAgentLaunchOperationStoreState(path, reversibleCipher(false)) + expect(reloaded.pending).toEqual([]) + expect(reloaded.settled).toEqual([settled('op-2')]) + }) + + it('returns empty state for a corrupt file', () => { + const path = agentLaunchOperationStorePath(dir) + writeFileSync(path, '{ not json', 'utf-8') + expect(loadAgentLaunchOperationStoreState(path, reversibleCipher(true))).toEqual({ + pending: [], + settled: [] + }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-operation-store-persistence.ts b/src/main/agent-launch/agent-launch-operation-store-persistence.ts new file mode 100644 index 00000000000..d8a89667269 --- /dev/null +++ b/src/main/agent-launch/agent-launch-operation-store-persistence.ts @@ -0,0 +1,165 @@ +// Host-private durable persistence for the launch-operation store (U4). Both +// durable halves live in ONE file under the host data dir, never client-synced: +// • the settled ledger — digests, status, terminal id, and failure id only, +// non-sensitive by construction, so it is written in plaintext for restart +// idempotency; +// • the pending snapshots — they carry argv, the admitted agent env, and the +// launch token, so they are encrypted at rest via Electron safeStorage (the +// existing secret-settings standard). A pending snapshot that outlives a +// main crash is what lets reconciliation re-attribute a terminal by its +// token, so this map must be durable, not memory-only. +// The file is written with the same atomic tmp+rename + permission-hardening +// discipline as the other host credential stores (writeSecureJsonFile). The +// encode/decode core takes an injected cipher so it is testable without Electron. + +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { safeStorage } from 'electron' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import type { + AgentLaunchOperationStoreDurableState, + PendingAgentLaunchSnapshot, + SettledAgentLaunchOperation +} from './agent-launch-operation-store' +import { getHostAgentLaunchOperationStore } from './agent-launch-operation-store-host' + +const STORE_FILENAME = 'agent-launch-operations.json' + +export function agentLaunchOperationStorePath(userDataPath: string): string { + return join(userDataPath, STORE_FILENAME) +} + +/** Crypto boundary for the encrypted pending section. Injected so the envelope + * round-trip is unit-testable without an Electron/OS keychain. */ +export type AgentLaunchOperationCipher = { + available: () => boolean + encrypt: (plaintext: string) => Buffer + decrypt: (ciphertext: Buffer) => string +} + +export function electronSafeStorageCipher(): AgentLaunchOperationCipher { + return { + available: () => safeStorage.isEncryptionAvailable(), + encrypt: (plaintext) => safeStorage.encryptString(plaintext), + decrypt: (ciphertext) => safeStorage.decryptString(ciphertext) + } +} + +type PersistedPendingSection = + | { format: 'electron-safe-storage-v1'; ciphertext: string } + // Plaintext fallback only when OS-backed encryption is unavailable; the file + // itself is still permission-hardened. Matches the secret-settings standard. + | { format: 'plaintext-v1'; snapshots: PendingAgentLaunchSnapshot[] } + +type PersistedFile = { + version: 1 + settled: SettledAgentLaunchOperation[] + pending: PersistedPendingSection +} + +export function encodeAgentLaunchOperationStore( + state: AgentLaunchOperationStoreDurableState, + cipher: AgentLaunchOperationCipher +): PersistedFile { + const snapshots = [...state.pending] + const pending: PersistedPendingSection = cipher.available() + ? { + format: 'electron-safe-storage-v1', + ciphertext: cipher.encrypt(JSON.stringify(snapshots)).toString('base64') + } + : { format: 'plaintext-v1', snapshots } + return { version: 1, settled: [...state.settled], pending } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodePending( + pending: unknown, + cipher: AgentLaunchOperationCipher +): PendingAgentLaunchSnapshot[] { + if (!isRecord(pending)) { + return [] + } + if (pending.format === 'plaintext-v1' && Array.isArray(pending.snapshots)) { + return pending.snapshots as PendingAgentLaunchSnapshot[] + } + if ( + pending.format === 'electron-safe-storage-v1' && + typeof pending.ciphertext === 'string' && + cipher.available() + ) { + // A decrypt failure (keychain reset) drops only the pending map, never the + // whole file: reconciliation then treats those launches conservatively + // rather than mis-attributing, and the settled ledger stays intact. + const decrypted = cipher.decrypt(Buffer.from(pending.ciphertext, 'base64')) + const parsed = JSON.parse(decrypted) + return Array.isArray(parsed) ? (parsed as PendingAgentLaunchSnapshot[]) : [] + } + return [] +} + +export function decodeAgentLaunchOperationStore( + raw: unknown, + cipher: AgentLaunchOperationCipher +): AgentLaunchOperationStoreDurableState { + if (!isRecord(raw) || raw.version !== 1) { + return { pending: [], settled: [] } + } + const settled = Array.isArray(raw.settled) ? (raw.settled as SettledAgentLaunchOperation[]) : [] + let pending: PendingAgentLaunchSnapshot[] + try { + pending = decodePending(raw.pending, cipher) + } catch { + pending = [] + } + return { pending, settled } +} + +export function loadAgentLaunchOperationStoreState( + path: string, + cipher: AgentLaunchOperationCipher +): AgentLaunchOperationStoreDurableState { + if (!existsSync(path)) { + return { pending: [], settled: [] } + } + try { + hardenExistingSecureFile(path) + return decodeAgentLaunchOperationStore(JSON.parse(readFileSync(path, 'utf-8')), cipher) + } catch { + // A corrupt ledger must never block boot; start empty and let the create/ + // retry path rebuild idempotency state from scratch. + return { pending: [], settled: [] } + } +} + +export function writeAgentLaunchOperationStoreState( + path: string, + state: AgentLaunchOperationStoreDurableState, + cipher: AgentLaunchOperationCipher +): void { + writeSecureJsonFile(path, encodeAgentLaunchOperationStore(state, cipher)) +} + +/** Boot-time wiring: rehydrate the durable state, then attach the write-back + * sink so every later mutation is persisted. Called once from the main-process + * startup after the user data dir is stable. The startup reconcile trigger that + * consumes rehydrated pending snapshots lands with its first producer; the data + * is made durable here regardless. */ +export function initHostAgentLaunchOperationStorePersistence(userDataPath: string): void { + const path = agentLaunchOperationStorePath(userDataPath) + const cipher = electronSafeStorageCipher() + const state = loadAgentLaunchOperationStoreState(path, cipher) + const store = getHostAgentLaunchOperationStore() + store.rebuildSettledFrom(state.settled) + store.rebuildPendingFrom(state.pending) + store.setDurablePersistence((next) => { + try { + writeAgentLaunchOperationStoreState(path, next, cipher) + } catch { + // A failed persist must not break the in-flight launch; the in-memory + // store stays authoritative and the next mutation retries the write. + } + }) +} diff --git a/src/main/agent-launch/agent-launch-operation-store.test.ts b/src/main/agent-launch/agent-launch-operation-store.test.ts new file mode 100644 index 00000000000..7a2505d90eb --- /dev/null +++ b/src/main/agent-launch/agent-launch-operation-store.test.ts @@ -0,0 +1,213 @@ +// Step 1 foundation: the host-private operation store's data-structure +// invariants — canonical digest determinism, idempotency-key stability, the +// per-scope settled-ledger bound, and in-flight snapshot lookups. Reconciliation +// and retry idempotency that consume these land in later steps. +import { describe, expect, it } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { + AgentLaunchOperationStore, + MAX_SETTLED_OPERATIONS_PER_SCOPE, + agentLaunchIdempotencyKey, + canonicalPayloadDigest, + mintAgentLaunchOperationId, + type PendingAgentLaunchSnapshot, + type SettledAgentLaunchOperation +} from './agent-launch-operation-store' + +const SNAPSHOT: AgentLaunchSnapshot = { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} + +function pending(overrides: Partial = {}): PendingAgentLaunchSnapshot { + return { + operationId: mintAgentLaunchOperationId(), + idempotencyKey: 'key-a', + scope: 'wt-1', + clientMutationId: null, + payloadDigest: 'digest-a', + launchToken: 'token-a', + intent: 'interactive', + snapshot: SNAPSHOT, + ...overrides + } +} + +function settled( + overrides: Partial = {} +): SettledAgentLaunchOperation { + return { + operationId: mintAgentLaunchOperationId(), + idempotencyKey: 'key-a', + scope: 'wt-1', + payloadDigest: 'digest-a', + status: 'launched', + terminalId: 'term-1', + failureId: null, + settledAt: 1, + ...overrides + } +} + +describe('canonicalPayloadDigest', () => { + it('is insensitive to property order and absent optional fields', () => { + const a = canonicalPayloadDigest({ action: { kind: 'retry-same' }, agent: 'claude' }) + const b = canonicalPayloadDigest({ agent: 'claude', action: { kind: 'retry-same' } }) + const c = canonicalPayloadDigest({ + agent: 'claude', + action: { kind: 'retry-same' }, + extra: undefined + }) + expect(a).toBe(b) + expect(a).toBe(c) + }) + + it('changes when a meaningful field changes', () => { + const base = canonicalPayloadDigest({ action: { kind: 'change-agent', agent: 'claude' } }) + const changed = canonicalPayloadDigest({ action: { kind: 'change-agent', agent: 'codex' } }) + expect(base).not.toBe(changed) + }) +}) + +describe('agentLaunchIdempotencyKey', () => { + it('is stable for identical inputs and varies by every component', () => { + const base = agentLaunchIdempotencyKey({ + principal: { kind: 'local' }, + scope: 'wt-1', + clientMutationId: 'm-1' + }) + expect( + agentLaunchIdempotencyKey({ + principal: { kind: 'local' }, + scope: 'wt-1', + clientMutationId: 'm-1' + }) + ).toBe(base) + expect( + agentLaunchIdempotencyKey({ + principal: { kind: 'local' }, + scope: 'wt-1', + clientMutationId: 'm-2' + }) + ).not.toBe(base) + expect( + agentLaunchIdempotencyKey({ + principal: { kind: 'local' }, + scope: 'wt-2', + clientMutationId: 'm-1' + }) + ).not.toBe(base) + expect( + agentLaunchIdempotencyKey({ + principal: { kind: 'remote', id: 'device-1' }, + scope: 'wt-1', + clientMutationId: 'm-1' + }) + ).not.toBe(base) + }) +}) + +describe('mintAgentLaunchOperationId', () => { + it('mints distinct ids', () => { + expect(mintAgentLaunchOperationId()).not.toBe(mintAgentLaunchOperationId()) + }) +}) + +describe('in-flight pending snapshots', () => { + it('stores, looks up by token and idempotency key, and clears', () => { + const store = new AgentLaunchOperationStore() + const entry = pending({ idempotencyKey: 'key-x', launchToken: 'token-x' }) + store.beginPending(entry) + expect(store.getPending('token-x')).toBe(entry) + expect(store.findPendingByIdempotencyKey('wt-1', 'key-x')).toBe(entry) + expect(store.findPendingByIdempotencyKey('wt-2', 'key-x')).toBeNull() + expect(store.pendingSnapshots()).toHaveLength(1) + expect(store.clearPending('token-x')).toBe(true) + expect(store.getPending('token-x')).toBeNull() + expect(store.pendingSnapshots()).toHaveLength(0) + }) + + it('rehydrates durable in-flight snapshots at startup', () => { + const store = new AgentLaunchOperationStore() + const a = pending({ launchToken: 'token-1', idempotencyKey: 'k1' }) + const b = pending({ launchToken: 'token-2', idempotencyKey: 'k2' }) + store.rebuildPendingFrom([a, b]) + expect(store.getPending('token-1')).toBe(a) + expect(store.getPending('token-2')).toBe(b) + }) +}) + +describe('settled ledger', () => { + it('retains only the newest entries per scope and isolates scopes', () => { + const store = new AgentLaunchOperationStore() + for (let index = 0; index < MAX_SETTLED_OPERATIONS_PER_SCOPE + 4; index += 1) { + store.recordSettled( + settled({ operationId: `op-${index}`, idempotencyKey: `k-${index}`, settledAt: index }) + ) + } + store.recordSettled(settled({ scope: 'wt-2', operationId: 'other', idempotencyKey: 'k-other' })) + const bucket = store.settledForScope('wt-1') + expect(bucket).toHaveLength(MAX_SETTLED_OPERATIONS_PER_SCOPE) + // Oldest four evicted; newest retained. + expect(bucket.at(0)?.operationId).toBe('op-4') + expect(bucket.at(-1)?.operationId).toBe(`op-${MAX_SETTLED_OPERATIONS_PER_SCOPE + 3}`) + expect(store.settledForScope('wt-2')).toHaveLength(1) + }) + + it('replaces an existing entry for the same operation rather than growing', () => { + const store = new AgentLaunchOperationStore() + store.recordSettled( + settled({ operationId: 'op-1', status: 'failed', failureId: 'f-1', terminalId: null }) + ) + store.recordSettled( + settled({ operationId: 'op-1', status: 'launched', terminalId: 't-1', failureId: null }) + ) + const bucket = store.settledForScope('wt-1') + expect(bucket).toHaveLength(1) + expect(bucket[0].status).toBe('launched') + expect(bucket[0].terminalId).toBe('t-1') + }) + + it('finds the newest settled entry by idempotency key', () => { + const store = new AgentLaunchOperationStore() + store.recordSettled( + settled({ operationId: 'op-1', idempotencyKey: 'k-1', status: 'failed', settledAt: 1 }) + ) + store.recordSettled( + settled({ operationId: 'op-2', idempotencyKey: 'k-1', status: 'launched', settledAt: 2 }) + ) + const found = store.findSettledByIdempotencyKey('wt-1', 'k-1') + expect(found?.operationId).toBe('op-2') + expect(store.findSettledByIdempotencyKey('wt-1', 'missing')).toBeNull() + expect(store.findSettledByIdempotencyKey('wt-9', 'k-1')).toBeNull() + }) + + it('rehydrates the settled ledger in chronological order under the bound', () => { + const store = new AgentLaunchOperationStore() + const entries: SettledAgentLaunchOperation[] = [] + for (let index = 0; index < MAX_SETTLED_OPERATIONS_PER_SCOPE + 3; index += 1) { + entries.push( + settled({ operationId: `op-${index}`, idempotencyKey: `k-${index}`, settledAt: index }) + ) + } + // Shuffle the durable order to prove rebuild sorts by settledAt before bounding. + store.rebuildSettledFrom(entries.toReversed()) + const bucket = store.settledForScope('wt-1') + expect(bucket).toHaveLength(MAX_SETTLED_OPERATIONS_PER_SCOPE) + expect(bucket.at(0)?.operationId).toBe('op-3') + expect(bucket.at(-1)?.operationId).toBe(`op-${MAX_SETTLED_OPERATIONS_PER_SCOPE + 2}`) + }) +}) diff --git a/src/main/agent-launch/agent-launch-operation-store.ts b/src/main/agent-launch/agent-launch-operation-store.ts new file mode 100644 index 00000000000..9e4e9b62fa0 Binary files /dev/null and b/src/main/agent-launch/agent-launch-operation-store.ts differ diff --git a/src/main/agent-launch/agent-launch-payload-caps.ts b/src/main/agent-launch/agent-launch-payload-caps.ts new file mode 100644 index 00000000000..3bd7a0be15f --- /dev/null +++ b/src/main/agent-launch/agent-launch-payload-caps.ts @@ -0,0 +1,72 @@ +// Conservative payload caps applied after env composition. These do not replace +// a lower provider limit (which stays spawn_failed); they fail closed before a +// writer runs so persisted/remote data and inherited env size cannot smuggle an +// oversized command or environment past resolution. + +import { Buffer } from 'node:buffer' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import { buildShellCommandFromArgv } from '../../shared/tui-agent-startup-shell' +import { + CMD_EXE_COMMAND_LINE_MAX_CHARS, + POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS +} from '../providers/windows-shell-args' +import { utf8ByteLength } from '../../shared/custom-tui-agent-fields' +import type { AgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { AgentArgv } from '../../shared/agent-launch-host-contract' +import { + measurePosixArgEnvBytes, + measureWindowsEnvironmentBlockCodeUnits, + POSIX_ARG_ENV_SAFE_MAX_BYTES, + POSIX_STARTUP_COMMAND_MAX_BYTES, + WINDOWS_ENVIRONMENT_BLOCK_MAX_CODE_UNITS, + type EnvLayer +} from './compose-agent-launch-env' + +/** PowerShell -EncodedCommand is base64 of the UTF-16LE command; this mirrors + * that length so the hard OS command-line ceiling is enforced pre-spawn. */ +function estimatePowerShellEncodedLength(commandText: string): number { + return Math.ceil(Buffer.byteLength(commandText, 'utf16le') / 3) * 4 +} + +/** Reject a command whose final shell form exceeds the target's hard OS limit. + * The 6000-char inline threshold is a delivery-path switch, not a failure, and + * lives in the startup writer (U3). */ +export function checkCommandTooLong( + argv: AgentArgv, + shell: AgentStartupShell +): AgentLaunchFailure | null { + const commandText = buildShellCommandFromArgv(argv, shell) + if (shell === 'cmd') { + return commandText.length > CMD_EXE_COMMAND_LINE_MAX_CHARS + ? { code: 'launch_command_too_long', shell } + : null + } + if (shell === 'powershell') { + return estimatePowerShellEncodedLength(commandText) > POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS + ? { code: 'launch_command_too_long', shell } + : null + } + return utf8ByteLength(commandText) > POSIX_STARTUP_COMMAND_MAX_BYTES + ? { code: 'launch_command_too_long', shell } + : null +} + +/** Reject an oversized effective environment. Native-Windows spawns measure the + * CreateProcess environment block; every other target measures the combined + * UTF-8 argv+env payload delivered as shell text. */ +export function checkEnvPayloadTooLarge( + argv: AgentArgv, + env: EnvLayer, + target: { platform: NodeJS.Platform; execution: 'native' | 'wsl'; isRemote: boolean } +): AgentLaunchFailure | null { + const isNativeWindowsSpawn = + target.platform === 'win32' && target.execution === 'native' && !target.isRemote + if (isNativeWindowsSpawn) { + return measureWindowsEnvironmentBlockCodeUnits(env) > WINDOWS_ENVIRONMENT_BLOCK_MAX_CODE_UNITS + ? { code: 'invalid_agent_env', field: 'env', reason: 'environment_block_too_large' } + : null + } + return measurePosixArgEnvBytes(argv, env) > POSIX_ARG_ENV_SAFE_MAX_BYTES + ? { code: 'invalid_agent_env', field: 'env', reason: 'arg_env_too_large' } + : null +} diff --git a/src/main/agent-launch/agent-launch-pending-summary-host.test.ts b/src/main/agent-launch/agent-launch-pending-summary-host.test.ts new file mode 100644 index 00000000000..852bf1751a3 --- /dev/null +++ b/src/main/agent-launch/agent-launch-pending-summary-host.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + agentLaunchExecutionHostDisplayName, + buildPendingAgentLaunchSummary +} from './agent-launch-pending-summary-host' +import type { AdmissionCapacityRow } from './agent-launch-admission-store' + +function row(over: Partial): AdmissionCapacityRow { + return { + intent: 'cli', + scope: 'wt-1', + admittedAt: 1, + launchToken: 'secret-tok', + baseHarness: 'codex', + executionHostId: 'local', + ...over + } +} + +describe('agentLaunchExecutionHostDisplayName', () => { + it('labels local, ssh (alias then id fallback), wsl distro, and runtime env', () => { + expect(agentLaunchExecutionHostDisplayName('local', () => undefined)).toBeTruthy() + expect( + agentLaunchExecutionHostDisplayName('ssh:prod', (t) => + t === 'prod' ? 'Prod box' : undefined + ) + ).toBe('Prod box') + expect(agentLaunchExecutionHostDisplayName('ssh:prod', () => undefined)).toBe('prod') + // wsl:${encodeURIComponent(distro)} — decoded back to the distro name. + expect(agentLaunchExecutionHostDisplayName('wsl:My%20Distro', () => undefined)).toBe( + 'My Distro' + ) + expect(agentLaunchExecutionHostDisplayName('runtime:env-9', () => undefined)).toBe('env-9') + }) + + it('never returns a path-shaped value for a display name', () => { + for (const id of ['local', 'ssh:prod', 'wsl:Ubuntu', 'runtime:env-1'] as const) { + expect(agentLaunchExecutionHostDisplayName(id, () => undefined)).not.toContain('/') + } + }) +}) + +describe('buildPendingAgentLaunchSummary', () => { + it('projects redacted rows and never emits the host-private launch token', () => { + const result = buildPendingAgentLaunchSummary( + [ + row({ + launchToken: 'secret-tok', + scope: 'wt-1', + intent: 'cli', + baseHarness: 'codex', + admittedAt: 42 + }) + ], + { + resolveLiveness: () => 'live', + resolveDeepLink: (r) => ({ kind: 'worktree', worktreeId: r.scope }), + sshLabelFor: () => undefined + } + ) + expect(result.rows[0]).toEqual({ + sourceKind: 'cli', + baseHarness: 'codex', + targetHostDisplayName: expect.any(String), + admittedAt: 42, + liveness: 'live', + deepLink: { kind: 'worktree', worktreeId: 'wt-1' } + }) + // The launch token is host-private and must never reach the client DTO. + expect(result.rows[0]).not.toHaveProperty('launchToken') + expect(JSON.stringify(result)).not.toContain('secret-tok') + }) + + it('omits deepLink when no owner resolves and passes injected liveness through', () => { + const result = buildPendingAgentLaunchSummary([row({})], { + resolveLiveness: () => 'absent', + resolveDeepLink: () => undefined, + sshLabelFor: () => undefined + }) + expect(result.rows[0]).not.toHaveProperty('deepLink') + expect(result.rows[0].liveness).toBe('absent') + }) +}) diff --git a/src/main/agent-launch/agent-launch-pending-summary-host.ts b/src/main/agent-launch/agent-launch-pending-summary-host.ts new file mode 100644 index 00000000000..ab723e66a2e --- /dev/null +++ b/src/main/agent-launch/agent-launch-pending-summary-host.ts @@ -0,0 +1,70 @@ +// Pure projection from redacted admission capacity rows to the client-safe +// pending-summary DTO. Dependency-injected (liveness, deep link, ssh label) and +// electron-free so it is unit-testable; the runtime supplies the host lookups. +// The launch token on each input row stays host-side — it is used only to feed +// the injected liveness resolver and is never copied into an output row. + +import { getLocalExecutionHostLabel, parseExecutionHostId } from '../../shared/execution-host' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import type { + PendingAgentLaunchDeepLink, + PendingAgentLaunchLiveness, + PendingAgentLaunchSummary, + PendingAgentLaunchSummaryRow +} from '../../shared/agent-launch-pending-summary' +import type { AdmissionCapacityRow } from './agent-launch-admission-store' + +/** User-facing display name for a launch's execution host. Composes the shared + * local/ssh/runtime labelers with this feature's `wsl:${distro}` arm (the shared + * grammar has no WSL variant). Returns a name, never a path. */ +export function agentLaunchExecutionHostDisplayName( + id: AgentLaunchExecutionHostId, + sshLabelFor: (targetId: string) => string | undefined +): string { + if (id === 'local') { + return getLocalExecutionHostLabel() + } + if (id.startsWith('wsl:')) { + try { + return decodeURIComponent(id.slice('wsl:'.length)) + } catch { + return id + } + } + const parsed = parseExecutionHostId(id) + if (parsed?.kind === 'ssh') { + return sshLabelFor(parsed.targetId) ?? parsed.targetId + } + if (parsed?.kind === 'runtime') { + return parsed.environmentId + } + return id +} + +export type PendingAgentLaunchSummaryDeps = { + resolveLiveness: (row: AdmissionCapacityRow) => PendingAgentLaunchLiveness + resolveDeepLink: (row: AdmissionCapacityRow) => PendingAgentLaunchDeepLink | undefined + sshLabelFor: (targetId: string) => string | undefined +} + +export function buildPendingAgentLaunchSummary( + rows: readonly AdmissionCapacityRow[], + deps: PendingAgentLaunchSummaryDeps +): PendingAgentLaunchSummary { + return { + rows: rows.map((row): PendingAgentLaunchSummaryRow => { + const deepLink = deps.resolveDeepLink(row) + return { + sourceKind: row.intent, + baseHarness: row.baseHarness, + targetHostDisplayName: agentLaunchExecutionHostDisplayName( + row.executionHostId, + deps.sshLabelFor + ), + admittedAt: row.admittedAt, + liveness: deps.resolveLiveness(row), + ...(deepLink ? { deepLink } : {}) + } + }) + } +} diff --git a/src/main/agent-launch/agent-launch-reconcile-intent-router.test.ts b/src/main/agent-launch/agent-launch-reconcile-intent-router.test.ts new file mode 100644 index 00000000000..cc02ab6c934 --- /dev/null +++ b/src/main/agent-launch/agent-launch-reconcile-intent-router.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { AgentLaunchIntentKind } from '../../shared/agent-launch-contract' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { PendingAgentLaunchSnapshot } from './agent-launch-operation-store' +import { + reconcilePersistenceForIntent, + type ReconcileIntentRouterArms +} from './agent-launch-reconcile-intent-router' +import type { ReconcileScopePersistence } from './agent-launch-worktree-reconcile-writer' + +function snapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function pending(intent: AgentLaunchIntentKind, scope: string): PendingAgentLaunchSnapshot { + return { + operationId: 'op-1', + idempotencyKey: 'idem-1', + scope, + clientMutationId: null, + payloadDigest: 'digest-1', + launchToken: 'token-1', + intent, + snapshot: snapshot() + } +} + +/** Arms that tag each returned slice with the family + scope it was built for, so + * a test can assert which arm handled a given intent and with which owner id. */ +function taggingArms(): { + arms: ReconcileIntentRouterArms + calls: { family: keyof ReconcileIntentRouterArms; scope: string }[] +} { + const calls: { family: keyof ReconcileIntentRouterArms; scope: string }[] = [] + const arm = + (family: keyof ReconcileIntentRouterArms) => + (scope: string): ReconcileScopePersistence => { + calls.push({ family, scope }) + return { settleLaunched: () => {}, settleFailed: () => {}, markUnknown: () => {} } + } + return { + calls, + arms: { + worktree: arm('worktree'), + automation: arm('automation'), + orchestration: arm('orchestration'), + background: arm('background') + } + } +} + +describe('reconcilePersistenceForIntent', () => { + it('routes interactive, cli, and resume to the worktree arm with the scope id', () => { + for (const intent of ['interactive', 'cli', 'resume'] as const) { + const { arms, calls } = taggingArms() + reconcilePersistenceForIntent(arms, pending(intent, 'wt-1')) + expect(calls).toEqual([{ family: 'worktree', scope: 'wt-1' }]) + } + }) + + it('routes automation to the automation arm with the run id', () => { + const { arms, calls } = taggingArms() + reconcilePersistenceForIntent(arms, pending('automation', 'run-9')) + expect(calls).toEqual([{ family: 'automation', scope: 'run-9' }]) + }) + + it('routes orchestration to the orchestration arm with the dispatch id', () => { + const { arms, calls } = taggingArms() + reconcilePersistenceForIntent(arms, pending('orchestration', 'dispatch-7')) + expect(calls).toEqual([{ family: 'orchestration', scope: 'dispatch-7' }]) + }) + + it('routes background to the background arm with the attempt id', () => { + const { arms, calls } = taggingArms() + reconcilePersistenceForIntent(arms, pending('background', 'attempt-3')) + expect(calls).toEqual([{ family: 'background', scope: 'attempt-3' }]) + }) + + it('never crosses families when two owners share a scope id namespace', () => { + const { arms, calls } = taggingArms() + reconcilePersistenceForIntent(arms, pending('background', 'shared-id')) + reconcilePersistenceForIntent(arms, pending('automation', 'shared-id')) + expect(calls).toEqual([ + { family: 'background', scope: 'shared-id' }, + { family: 'automation', scope: 'shared-id' } + ]) + }) +}) diff --git a/src/main/agent-launch/agent-launch-reconcile-intent-router.ts b/src/main/agent-launch/agent-launch-reconcile-intent-router.ts new file mode 100644 index 00000000000..ca134b767c0 --- /dev/null +++ b/src/main/agent-launch/agent-launch-reconcile-intent-router.ts @@ -0,0 +1,45 @@ +// Routes a reconciling pending launch to its owner record's persistence slice by +// INTENT (U6). Each unattended launch kind lands its reconciled outcome in a +// different owner store — background attempt, automation run, orchestration +// dispatch, or the interactive worktree meta — so a background attempt's failure +// never overwrites a worktree's launch card even if their scope ids collide. The +// arms are injected so this stays electron-free and unit-testable; the runtime +// binds each arm to its concrete store write. + +import type { PendingAgentLaunchSnapshot } from './agent-launch-operation-store' +import type { ReconcileScopePersistence } from './agent-launch-worktree-reconcile-writer' + +/** Owner-record persistence factories, one per launch-intent family. Each takes + * the pending's scope id (the owner bucket: worktree id, run id, dispatch id, or + * attempt id) and returns the tri-state writer the reconciler drives. */ +export type ReconcileIntentRouterArms = { + /** interactive / cli / resume launches — scope is a worktree id. */ + worktree: (worktreeId: string) => ReconcileScopePersistence + /** automation launches — scope is an automation run id. */ + automation: (runId: string) => ReconcileScopePersistence + /** orchestration launches — scope is a dispatch context id. */ + orchestration: (dispatchId: string) => ReconcileScopePersistence + /** background launches — scope is a background attempt id. */ + background: (attemptId: string) => ReconcileScopePersistence +} + +/** Pick the owner-record persistence slice for one pending launch by its intent. + * interactive/cli/resume all resolve to the worktree writer (they share the + * WorktreeMeta launch card); the three unattended kinds each get their own. */ +export function reconcilePersistenceForIntent( + arms: ReconcileIntentRouterArms, + pending: PendingAgentLaunchSnapshot +): ReconcileScopePersistence { + switch (pending.intent) { + case 'interactive': + case 'cli': + case 'resume': + return arms.worktree(pending.scope) + case 'automation': + return arms.automation(pending.scope) + case 'orchestration': + return arms.orchestration(pending.scope) + case 'background': + return arms.background(pending.scope) + } +} diff --git a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.test.ts b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.test.ts new file mode 100644 index 00000000000..b9005b4c500 --- /dev/null +++ b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { AgentLaunchIntentKind } from '../../shared/agent-launch-contract' +import { + AgentLaunchOperationStore, + type PendingAgentLaunchSnapshot +} from './agent-launch-operation-store' +import { BackgroundAgentLaunchStore } from './background-agent-launch-store' +import { + buildReconcileAgentLaunchDeps, + type LiveTerminalForToken, + type ReconcileRuntimeDeps +} from './agent-launch-reconcile-runtime-deps' +import type { ReconcileIntentRouterArms } from './agent-launch-reconcile-intent-router' +import { + reconcileOnePendingAgentLaunch, + type ReconcileScopePersistence +} from './agent-launch-worktree-reconcile-writer' + +function snapshot(executionHostId: AgentLaunchExecutionHostId): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: executionHostId !== 'local', + executionHostId + } + } +} + +function pending( + overrides: Partial = {}, + executionHostId: AgentLaunchExecutionHostId = 'local' +): PendingAgentLaunchSnapshot { + return { + operationId: 'op-1', + idempotencyKey: 'idem-1', + scope: 'wt-1', + clientMutationId: null, + payloadDigest: 'digest-1', + launchToken: 'token-1', + intent: 'interactive' as AgentLaunchIntentKind, + snapshot: snapshot(executionHostId), + ...overrides + } +} + +function spyArm(): ReconcileScopePersistence & { calls: string[] } { + const calls: string[] = [] + return { + calls, + settleLaunched: () => calls.push('launched'), + settleFailed: () => calls.push('failed'), + markUnknown: () => calls.push('unknown') + } +} + +function buildDeps( + overrides: Partial & { + liveTerminalByToken?: (token: string) => LiveTerminalForToken | null + arms?: ReconcileIntentRouterArms + } +): { store: AgentLaunchOperationStore; deps: ReturnType } { + const store = new AgentLaunchOperationStore() + const noopArm = (): ReconcileScopePersistence => spyArm() + const runtimeDeps: ReconcileRuntimeDeps = { + operationStore: store, + liveTerminalByToken: overrides.liveTerminalByToken ?? (() => null), + isHostAuthoritative: overrides.isHostAuthoritative ?? ((id) => id === 'local'), + expectedWorktreeId: overrides.expectedWorktreeId ?? ((p) => p.scope), + arms: overrides.arms ?? { + worktree: noopArm, + automation: noopArm, + orchestration: noopArm, + background: noopArm + }, + settleBoundary: overrides.settleBoundary ?? vi.fn(), + mintFailureId: overrides.mintFailureId ?? (() => 'failure-1'), + now: () => 1000 + } + return { store, deps: buildReconcileAgentLaunchDeps(runtimeDeps) } +} + +describe('buildReconcileAgentLaunchDeps liveness', () => { + it('resolves a live token in the launch worktree as attributed', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + liveTerminalByToken: () => ({ ptyId: 'term-9', worktreeId: 'wt-1' }), + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending() + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'launched' }) + expect(arm.calls).toEqual(['launched']) + }) + + it('resolves a live token in a different worktree as unattributed (theft class)', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + liveTerminalByToken: () => ({ ptyId: 'term-hijack', worktreeId: 'wt-OTHER' }), + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending() + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'invalid_launch_snapshot' }) + expect(arm.calls).toEqual(['failed']) + }) + + it('settles a non-live local pending as absent → spawn_failed (host is authoritative)', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + isHostAuthoritative: (id) => id === 'local', + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({}, 'local') + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'spawn_failed' }) + expect(arm.calls).toEqual(['failed']) + }) + + it('keeps a non-live remote pending unknown when its host is not authoritative', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + isHostAuthoritative: (id) => id === 'local', + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({ launchToken: 'token-r' }, 'ssh:host-a') + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'launch_state_unknown' }) + expect(arm.calls).toEqual(['unknown']) + // Coexistence: the reservation and pending survive for a later reconnect probe. + expect(store.getPending('token-r')).not.toBeNull() + }) + + it('settles a remote pending absent once its host becomes authoritative (reconnect probe)', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + isHostAuthoritative: (id) => id === 'local' || id === 'ssh:host-a', + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({ launchToken: 'token-r' }, 'ssh:host-a') + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'spawn_failed' }) + expect(arm.calls).toEqual(['failed']) + }) + + it('routes a background pending to the background store keyed by attempt id', () => { + const background = new BackgroundAgentLaunchStore({ now: () => 1000 }) + background.create({ + attemptId: 'attempt-7', + worktreeId: 'wt-bg', + operationId: 'op-bg', + requestedAgent: 'claude', + baseAgent: 'claude' + }) + const { store, deps } = buildDeps({ + isHostAuthoritative: () => true, + expectedWorktreeId: () => 'wt-bg', + arms: { + worktree: () => spyArm(), + automation: () => spyArm(), + orchestration: () => spyArm(), + background: (attemptId) => background.persistenceForAttempt(attemptId) + } + }) + const entry = pending( + { scope: 'attempt-7', launchToken: 'token-bg', intent: 'background' }, + 'local' + ) + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + // Local + no live token → absent → spawn_failed lands in the attempt record. + expect(outcome).toEqual({ kind: 'spawn_failed' }) + expect(background.get('attempt-7')?.state).toBe('failed') + expect(background.get('attempt-7')?.failure?.code).toBe('spawn_failed') + }) +}) diff --git a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts new file mode 100644 index 00000000000..1f07f297a23 --- /dev/null +++ b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts @@ -0,0 +1,80 @@ +// Builds the ReconcileAgentLaunchDeps the runtime drives from injected host +// primitives (U6). Keeps the liveness-resolution + owner-routing wiring pure and +// electron-free so it is unit-testable away from the 20k-line runtime; the +// runtime supplies the concrete token probe, host-authority predicate, and owner +// writers. +// +// Liveness follows the plan's reconciliation contract (487-513) exactly: +// - A launch token matched to a live terminal → `live`; `attributed` is whether +// that terminal still belongs to the launch's worktree (an unattributed live +// token is the pane-identity-theft class → invalid_launch_snapshot). +// - No live token match → `absent` ONLY when the pending's execution host is +// currently authoritatively listable (local in-process terminals died with +// main; a reconnected provider just re-listed its terminals). Otherwise the +// host is a possibly-unreachable survivor → `unknown` (non-retryable, durable) +// until its own terminal-list/reconnect event re-probes. `isHostAuthoritative` +// encodes which hosts a given reconcile pass can speak for, so a daemon/SSH +// survivor is never falsely settled `absent` before its provider reconnects. + +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import type { PendingAgentLaunchSnapshot } from './agent-launch-operation-store' +import type { AgentLaunchOperationStore } from './agent-launch-operation-store' +import { + reconcilePersistenceForIntent, + type ReconcileIntentRouterArms +} from './agent-launch-reconcile-intent-router' +import type { + ReconcileAgentLaunchDeps, + ResolvedLaunchLiveness +} from './agent-launch-worktree-reconcile-writer' + +/** A live terminal a launch token currently maps to. `worktreeId` is compared to + * the launch's expected worktree for attribution. */ +export type LiveTerminalForToken = { ptyId: string; worktreeId: string } + +export type ReconcileRuntimeDeps = { + operationStore: AgentLaunchOperationStore + /** The live terminal holding a launch token, or null if none is live. */ + liveTerminalByToken: (launchToken: string) => LiveTerminalForToken | null + /** Whether a non-live pending's host can be spoken for authoritatively in this + * reconcile pass (→ `absent`); false leaves it `unknown`. */ + isHostAuthoritative: (executionHostId: AgentLaunchExecutionHostId) => boolean + /** The worktree a live token must belong to for attribution: the scope for a + * worktree launch, the attempt's worktree for a background launch, or null when + * the intent has no worktree to compare (attribution then trusts the token). */ + expectedWorktreeId: (pending: PendingAgentLaunchSnapshot) => string | null + arms: ReconcileIntentRouterArms + settleBoundary: (launchToken: string, settlement: 'registered' | 'failed') => void + mintFailureId: () => string + now?: () => number +} + +function resolveLiveness( + deps: ReconcileRuntimeDeps, + pending: PendingAgentLaunchSnapshot +): ResolvedLaunchLiveness { + const live = deps.liveTerminalByToken(pending.launchToken) + if (live) { + const expected = deps.expectedWorktreeId(pending) + return { + kind: 'live', + attributed: expected === null || live.worktreeId === expected, + terminalId: live.ptyId + } + } + const host = pending.snapshot.target.executionHostId + return deps.isHostAuthoritative(host) ? { kind: 'absent' } : { kind: 'unknown' } +} + +export function buildReconcileAgentLaunchDeps( + deps: ReconcileRuntimeDeps +): ReconcileAgentLaunchDeps { + return { + operationStore: deps.operationStore, + resolveLiveness: (pending) => resolveLiveness(deps, pending), + persistenceFor: (pending) => reconcilePersistenceForIntent(deps.arms, pending), + settleBoundary: deps.settleBoundary, + mintFailureId: deps.mintFailureId, + now: deps.now + } +} diff --git a/src/main/agent-launch/agent-launch-reconciliation.test.ts b/src/main/agent-launch/agent-launch-reconciliation.test.ts new file mode 100644 index 00000000000..91907257c2a --- /dev/null +++ b/src/main/agent-launch/agent-launch-reconciliation.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + reconcileAgentLaunchLiveness, + retryRecoveryGateForFailureCode +} from './agent-launch-reconciliation' + +describe('reconcileAgentLaunchLiveness', () => { + it('live + attributed settles launched', () => { + expect(reconcileAgentLaunchLiveness({ kind: 'live', attributed: true })).toEqual({ + kind: 'launched' + }) + }) + + it('live + unattributed records invalid_launch_snapshot', () => { + expect(reconcileAgentLaunchLiveness({ kind: 'live', attributed: false })).toEqual({ + kind: 'invalid_launch_snapshot' + }) + }) + + it('absent settles spawn_failed so retry becomes available', () => { + expect(reconcileAgentLaunchLiveness({ kind: 'absent' })).toEqual({ kind: 'spawn_failed' }) + }) + + it('unknown keeps the launch pending as launch_state_unknown', () => { + expect(reconcileAgentLaunchLiveness({ kind: 'unknown' })).toEqual({ + kind: 'launch_state_unknown' + }) + }) +}) + +describe('retryRecoveryGateForFailureCode', () => { + it('blocks retry while liveness is unknown', () => { + expect(retryRecoveryGateForFailureCode('launch_state_unknown')).toEqual({ + kind: 'launch_state_unknown' + }) + }) + + it('blocks retry while a token-live terminal lacks attribution', () => { + expect(retryRecoveryGateForFailureCode('invalid_launch_snapshot')).toEqual({ + kind: 'invalid_launch_snapshot' + }) + }) + + it('treats an ordinary spawn failure as retryable', () => { + expect(retryRecoveryGateForFailureCode('spawn_failed')).toEqual({ kind: 'retryable' }) + }) + + it('treats an absent durable failure as retryable', () => { + expect(retryRecoveryGateForFailureCode(undefined)).toEqual({ kind: 'retryable' }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-reconciliation.ts b/src/main/agent-launch/agent-launch-reconciliation.ts new file mode 100644 index 00000000000..86b0f347cbc --- /dev/null +++ b/src/main/agent-launch/agent-launch-reconciliation.ts @@ -0,0 +1,63 @@ +// Pure tri-state reconciliation for a pending agent launch (U4/U5). The provider +// reports one of three liveness results for a launch token — live, absent, or +// unknown — and this maps them to the four persisted recovery outcomes in the +// plan's table. It NEVER polls or sleeps: provider reconnect / terminal-list +// events rerun it. Absence is authoritative only for providers whose terminals +// die with main (local in-process PTYs); daemon/SSH/WSL-relay/remote-runtime +// terminals may outlive main, so their `absent` must come from a real provider +// listing, and a disconnected provider is `unknown`, never a false `absent` that +// would enable a duplicate retry. Electron-free and injectable. + +import type { AgentLaunchFailureCode } from '../../shared/agent-launch-contract' +import type { RetryRecoveryGate } from './agent-launch-worktree-retry' + +/** Provider liveness for a launch token. `attributed` is whether the live + * terminal still carries a matching private snapshot/token attribution; a + * token-matched terminal without it cannot be trusted as the launched agent. */ +export type ProviderLiveness = + | { kind: 'live'; attributed: boolean } + | { kind: 'absent' } + | { kind: 'unknown' } + +/** Reconciled outcome, one per row of the plan's reconciliation table. */ +export type AgentLaunchReconcileOutcome = + // Settle launched, clear pending/failure, never spawn again. + | { kind: 'launched' } + // Token-live but unattributed: record failed/invalid_launch_snapshot, keep the + // terminal visible, disable Retry/Choose while live, never spawn a duplicate. + | { kind: 'invalid_launch_snapshot' } + // Absent: settle failed/spawn_failed; Retry becomes available. + | { kind: 'spawn_failed' } + // Unknown: keep pending, show "Launch state unavailable", spawn/tear down nothing. + | { kind: 'launch_state_unknown' } + +export function reconcileAgentLaunchLiveness( + liveness: ProviderLiveness +): AgentLaunchReconcileOutcome { + switch (liveness.kind) { + case 'live': + return liveness.attributed ? { kind: 'launched' } : { kind: 'invalid_launch_snapshot' } + case 'absent': + return { kind: 'spawn_failed' } + case 'unknown': + return { kind: 'launch_state_unknown' } + } +} + +/** Retry recovery gate derived from the CURRENT persisted failure code, not a + * live probe: reconciliation is event-driven and has already written the code + * the recovery card renders, so the server-side gate reads that same state. The + * two blocking codes (launch_state_unknown while liveness is unknown, + * invalid_launch_snapshot while a token-live terminal lacks attribution) fail + * the retry WITHOUT mutation; every other durable failure is retryable. */ +export function retryRecoveryGateForFailureCode( + code: AgentLaunchFailureCode | undefined +): RetryRecoveryGate { + if (code === 'launch_state_unknown') { + return { kind: 'launch_state_unknown' } + } + if (code === 'invalid_launch_snapshot') { + return { kind: 'invalid_launch_snapshot' } + } + return { kind: 'retryable' } +} diff --git a/src/main/agent-launch/agent-launch-resume-ingest.test.ts b/src/main/agent-launch/agent-launch-resume-ingest.test.ts new file mode 100644 index 00000000000..68fef070973 --- /dev/null +++ b/src/main/agent-launch/agent-launch-resume-ingest.test.ts @@ -0,0 +1,254 @@ +// U5: resume/fork ingestion resolves the private record by ownership key and +// produces the resume-specific launch inputs (v1-snapshot replay or opaque legacy +// replay), or an in-band invalid_launch_snapshot that never silently substitutes +// current config. +import { describe, expect, it } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { + AgentSessionOwnershipKey, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + resolveResumeLaunchIngest, + type ResumeLaunchIngestInput +} from './agent-launch-resume-ingest' + +function snapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'custom-agent:claude:reviewer', + baseAgent: 'claude', + displayLabel: 'Reviewer', + mode: 'custom', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +const KEY: AgentSessionOwnershipKey = { + worktreeId: 'wt-1', + baseAgent: 'claude', + providerSessionId: 'sess-1' +} + +const LEGACY_CONFIG: SleepingAgentLaunchConfig = { + agentCommand: 'claude', + agentArgs: '--model opus', + agentEnv: { FOO: 'bar' } +} + +/** Desktop trusted context with an optional first-resume handoff. */ +function desktopLegacy( + handoff?: { launchConfig: SleepingAgentLaunchConfig; recordedConnectionId: string | null }, + connectionId: string | null = null +): ResumeLaunchIngestInput['legacy'] { + return { shell: 'posix', connectionId, ...(handoff ? { handoff } : {}) } +} + +function storeWithBoundRecord(): AgentSessionRecordStore { + const store = new AgentSessionRecordStore() + store.register({ + paneKey: 'pane-a', + terminalId: 'term-a', + worktreeId: 'wt-1', + requestedAgent: 'custom-agent:claude:reviewer', + baseAgent: 'claude', + launchSnapshot: snapshot(), + launchToken: 'token-a' + }) + store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' }) + return store +} + +describe('resolveResumeLaunchIngest — v1 snapshot', () => { + it('produces resume inputs from a bound v1-snapshot record', () => { + const result = resolveResumeLaunchIngest( + { resume: { operation: 'resume', sessionKey: KEY }, client: 'desktop' }, + storeWithBoundRecord() + ) + expect(result.ok && result.kind).toBe('snapshot') + if (!result.ok || result.kind !== 'snapshot') { + return + } + expect(result.request.selection).toEqual({ + kind: 'agent', + agent: 'custom-agent:claude:reviewer' + }) + expect(result.request.sourceRecord).toEqual({ owner: 'session' }) + expect(result.request.allowEmptyPromptLaunch).toBe(true) + expect(result.request.prompt).toBeUndefined() + expect(result.intent).toEqual({ kind: 'resume', operation: 'resume', client: 'desktop' }) + expect(result.persistedSnapshot).toEqual(snapshot()) + expect(result.resumeProviderSession).toEqual({ key: 'session_id', id: 'sess-1' }) + }) + + it('carries the fork operation into the intent', () => { + const result = resolveResumeLaunchIngest( + { resume: { operation: 'fork', sessionKey: KEY }, client: 'desktop' }, + storeWithBoundRecord() + ) + expect(result.ok && result.intent).toMatchObject({ kind: 'resume', operation: 'fork' }) + }) + + it('maps the authenticated client into the resume intent', () => { + const result = resolveResumeLaunchIngest( + { resume: { operation: 'resume', sessionKey: KEY }, client: 'mobile' }, + storeWithBoundRecord() + ) + expect(result.ok && result.intent).toMatchObject({ client: 'mobile' }) + }) + + it('returns invalid_launch_snapshot for an unknown ownership key', () => { + const result = resolveResumeLaunchIngest( + { + resume: { + operation: 'resume', + sessionKey: { worktreeId: 'wt-x', baseAgent: 'codex', providerSessionId: 'nope' } + }, + client: 'desktop' + }, + storeWithBoundRecord() + ) + expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } }) + }) +}) + +describe('resolveResumeLaunchIngest — opaque legacy replay', () => { + it('persists the surrendered config once and replays it opaquely on first resume', () => { + const store = new AgentSessionRecordStore() + const result = resolveResumeLaunchIngest( + { + resume: { operation: 'resume', sessionKey: KEY }, + client: 'desktop', + legacy: desktopLegacy({ launchConfig: LEGACY_CONFIG, recordedConnectionId: null }) + }, + store + ) + expect(result.ok && result.kind).toBe('legacy') + if (!result.ok || result.kind !== 'legacy') { + return + } + expect(result.baseAgent).toBe('claude') + expect(result.requestedAgent).toBe('claude') + // Base command + args, then the appended provider resume flags (one-shot only). + expect(result.launchCommand).toContain('claude') + expect(result.launchCommand).toContain('--model') + expect(result.launchCommand).toContain('--resume') + expect(result.launchCommand).toContain('sess-1') + // Durable config stays base-only so a fresh relaunch never re-resumes. + expect(result.launchConfig.agentArgs).toBe('--model opus') + expect(result.launchConfig.agentArgs).not.toContain('--resume') + // Persist-once: the host now owns the record and a second resume needs no handoff. + const stored = store.resolveByOwnershipKey(KEY) + expect(stored?.legacyLaunchConfig).toEqual(LEGACY_CONFIG) + const second = resolveResumeLaunchIngest( + { + resume: { operation: 'resume', sessionKey: KEY }, + client: 'desktop', + legacy: desktopLegacy() + }, + store + ) + expect(second.ok && second.kind).toBe('legacy') + }) + + it('strips Orca attribution env before replay', () => { + const store = new AgentSessionRecordStore() + const result = resolveResumeLaunchIngest( + { + resume: { operation: 'resume', sessionKey: KEY }, + client: 'desktop', + legacy: desktopLegacy({ + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { FOO: 'bar', ORCA_PANE_KEY: 'pane', TMUX: 'x' } + }, + recordedConnectionId: null + }) + }, + store + ) + expect(result.ok && result.kind === 'legacy' && result.launchConfig.agentEnv).toEqual({ + FOO: 'bar' + }) + }) + + it('fails closed when the recorded execution owner no longer matches', () => { + const store = new AgentSessionRecordStore() + const result = resolveResumeLaunchIngest( + { + resume: { operation: 'resume', sessionKey: KEY }, + client: 'desktop', + legacy: desktopLegacy( + { launchConfig: LEGACY_CONFIG, recordedConnectionId: 'ssh:old' }, + 'ssh:new' + ) + }, + store + ) + expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } }) + // Never a partial write: an owner mismatch leaves the store untouched. + expect(store.resolveByOwnershipKey(KEY)).toBeNull() + }) + + it('fails closed on an invalid surviving env and never writes the record', () => { + const store = new AgentSessionRecordStore() + const result = resolveResumeLaunchIngest( + { + resume: { operation: 'resume', sessionKey: KEY }, + client: 'desktop', + legacy: desktopLegacy({ + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: { BAD: 'a\u0000b' } }, + recordedConnectionId: null + }) + }, + store + ) + expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } }) + expect(store.resolveByOwnershipKey(KEY)).toBeNull() + }) + + it('never opaque-replays a stored legacy record for a non-desktop client', () => { + const store = new AgentSessionRecordStore() + store.ingestLegacyRecord({ + ownershipKey: KEY, + requestedAgent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + legacyLaunchConfig: LEGACY_CONFIG, + connectionId: null + }) + // Mobile/paired never carry the trusted legacy context, so the record fails + // closed to "Launch with current settings". + const result = resolveResumeLaunchIngest( + { resume: { operation: 'resume', sessionKey: KEY }, client: 'mobile' }, + store + ) + expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } }) + }) + + it('returns invalid_launch_snapshot for a legacy record with no v1 snapshot when no trusted context', () => { + const store = new AgentSessionRecordStore() + store.ingestLegacyRecord({ + ownershipKey: KEY, + requestedAgent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + legacyLaunchConfig: LEGACY_CONFIG, + connectionId: null + }) + const result = resolveResumeLaunchIngest( + { resume: { operation: 'resume', sessionKey: KEY }, client: 'desktop' }, + store + ) + expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-resume-ingest.ts b/src/main/agent-launch/agent-launch-resume-ingest.ts new file mode 100644 index 00000000000..35019de991a --- /dev/null +++ b/src/main/agent-launch/agent-launch-resume-ingest.ts @@ -0,0 +1,180 @@ +// Host ingestion for the provider-session resume/fork variant (U5). A resume +// request names only the session ownership key; this module loads the host-private +// record and produces the resume-specific launch inputs the shared spawn pipeline +// consumes. A v1-snapshot record replays through resolveAgentLaunch's snapshot +// path (structured argv); a one-release legacy record replays OPAQUELY through +// agent-launch-legacy-replay (pre-quoted command), which bypasses the resolver. +// +// Precedence per plan §575: a present valid v1 snapshot replays; else a present +// valid + eligible legacy config replays (desktop/host-initiated only); else a +// record with neither field, or no record at all, returns in-band +// `invalid_launch_snapshot` (a persisted launch-attempt failure, NOT a request +// error) so the client offers "Launch with current settings" rather than silently +// substituting current config. A present-but-invalid value fails the same way and +// leaves the source record unchanged (never a partial write). + +import type { AgentLaunchSnapshot, LaunchIntent } from '../../shared/agent-launch-host-contract' +import type { + AgentLaunchResumeRequest, + AgentLaunchSpawnRequest +} from '../../shared/agent-launch-spawn-request' +import type { TuiAgent } from '../../shared/types' +import { + providerSessionKeyForResumableBase, + type AgentProviderSessionMetadata, + type ResumableTuiAgent, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import { buildLegacyResumeReplay } from './agent-launch-legacy-replay' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +/** Client kind for the resume intent, mapped host-side from the authenticated + * scope — never copied from client payload. */ +export type ResumeLaunchClient = 'desktop' | 'paired-web' | 'mobile' + +export type ResumeLaunchIngestInput = { + resume: AgentLaunchResumeRequest['resume'] + client: ResumeLaunchClient + /** Trusted desktop-only opaque legacy replay context. Present only on the + * in-process pty:spawn surface; absent on runtime/mobile/paired RPC, so a + * legacy record there resolves to invalid_launch_snapshot per the migration + * rules (opaque replay is desktop/host-initiated only). */ + legacy?: { + shell: AgentStartupShell + /** Current spawn's execution owner, for legacy provenance. */ + connectionId: string | null + /** The pre-quoted config the renderer surrenders over trusted IPC on first + * resume of a pre-U5 session; absent once the host owns the record. */ + handoff?: { launchConfig: SleepingAgentLaunchConfig; recordedConnectionId: string | null } + } +} + +/** A v1-snapshot resume: merged with host-context target/variables/scope/principal + * into an AgentLaunchSpawnInput and resolved through the snapshot replay path. */ +export type ResumeSnapshotIngest = { + ok: true + kind: 'snapshot' + request: AgentLaunchSpawnRequest + intent: LaunchIntent + persistedSnapshot: AgentLaunchSnapshot + resumeProviderSession: AgentProviderSessionMetadata +} + +/** An opaque legacy resume: the launchCommand/launchConfig feed the pre-U5 spawn + * fields directly, bypassing the resolver (no admission token/receipt). */ +export type ResumeLegacyIngest = { + ok: true + kind: 'legacy' + intent: LaunchIntent + requestedAgent: TuiAgent + baseAgent: ResumableTuiAgent + launchCommand: string + launchConfig: SleepingAgentLaunchConfig +} + +export type ResumeLaunchIngestResult = + | ResumeSnapshotIngest + | ResumeLegacyIngest + | { ok: false; failure: { code: 'invalid_launch_snapshot' } } + +const INVALID = { ok: false, failure: { code: 'invalid_launch_snapshot' } } as const + +/** Resolve a resume/fork request against the private record store. */ +export function resolveResumeLaunchIngest( + input: ResumeLaunchIngestInput, + store: AgentSessionRecordStore +): ResumeLaunchIngestResult { + const intent: LaunchIntent = { + kind: 'resume', + operation: input.resume.operation, + client: input.client + } + const record = store.resolveByOwnershipKey(input.resume.sessionKey) + + if (record?.launchSnapshot) { + // The record's requested identity resolves the same base the snapshot pins; + // the resolver's replay path re-checks the snapshot/identity match. `session` + // marks the reference authority so a live picker cannot forge it. + return { + ok: true, + kind: 'snapshot', + request: { + selection: { kind: 'agent', agent: record.requestedAgent }, + // Resume launches a bare TUI (no client prompt); the provider resume flags + // come from the snapshot replay, not a prompt. + allowEmptyPromptLaunch: true, + sourceRecord: { owner: 'session' } + }, + intent, + persistedSnapshot: record.launchSnapshot, + resumeProviderSession: record.providerSession + } + } + + // Opaque legacy replay is desktop/host-initiated only; the trusted context is + // absent on every untrusted surface, so those legacy resumes fail closed. + if (input.legacy && input.client === 'desktop') { + if (record?.legacyLaunchConfig) { + // Host already owns the config: re-validate provenance and replay from it. + const replay = buildLegacyResumeReplay({ + legacyLaunchConfig: record.legacyLaunchConfig, + requestedAgent: record.requestedAgent, + baseAgent: record.baseAgent, + providerSession: record.providerSession, + shell: input.legacy.shell, + recordedConnectionId: record.legacyConnectionId ?? null, + currentConnectionId: input.legacy.connectionId + }) + return replay.ok ? { ok: true, kind: 'legacy', intent, ...replayFields(replay) } : INVALID + } + if (!record && input.legacy.handoff) { + // First resume of a pre-U5 session: the renderer surrenders the config. + // A legacy record's requested identity equals its base (migration rule). + const baseAgent = input.resume.sessionKey.baseAgent + const providerSession: AgentProviderSessionMetadata = { + key: providerSessionKeyForResumableBase(baseAgent), + id: input.resume.sessionKey.providerSessionId + } + const replay = buildLegacyResumeReplay({ + legacyLaunchConfig: input.legacy.handoff.launchConfig, + requestedAgent: baseAgent, + baseAgent, + providerSession, + shell: input.legacy.shell, + recordedConnectionId: input.legacy.handoff.recordedConnectionId, + currentConnectionId: input.legacy.connectionId + }) + if (!replay.ok) { + // Validation failed: leave the store untouched (never a partial write). + return INVALID + } + // Persist-once: the host owns the config thereafter, so a later resume works + // without the renderer re-sending it (the client field is deleted next + // release). Validation ran first, so this write is only ever a valid config. + store.ingestLegacyRecord({ + ownershipKey: input.resume.sessionKey, + requestedAgent: baseAgent, + providerSession, + legacyLaunchConfig: input.legacy.handoff.launchConfig, + connectionId: input.legacy.handoff.recordedConnectionId + }) + return { ok: true, kind: 'legacy', intent, ...replayFields(replay) } + } + } + + // No record, a record without a replayable field, or a legacy record reached + // over an untrusted surface: never silently resolve current config. + return INVALID +} + +function replayFields( + replay: Extract, { ok: true }> +): Pick { + return { + requestedAgent: replay.requestedAgent, + baseAgent: replay.baseAgent, + launchCommand: replay.launchCommand, + launchConfig: replay.launchConfig + } +} diff --git a/src/main/agent-launch/agent-launch-setup-sequence-wrap.test.ts b/src/main/agent-launch/agent-launch-setup-sequence-wrap.test.ts new file mode 100644 index 00000000000..ec65648c5cf --- /dev/null +++ b/src/main/agent-launch/agent-launch-setup-sequence-wrap.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { wrapAgentPlanWithSetupSequence } from './agent-launch-setup-sequence-wrap' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { WorktreeSetupLaunch } from '../../shared/types' + +const PLAN: AgentStartupPlan = { + agent: 'claude', + launchCommand: 'claude --resume', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + env: { ORCA_AGENT_ENV: 'user-value' } +} + +const SETUP: WorktreeSetupLaunch = { + runnerScriptPath: '/wt/.orca/setup.sh', + waitForAgentStartup: true +} as WorktreeSetupLaunch + +describe('wrapAgentPlanWithSetupSequence', () => { + it('passes the plan through unchanged when setup does not wait for agent startup', () => { + const wrapped = wrapAgentPlanWithSetupSequence(PLAN, undefined) + expect(wrapped.command).toBe('claude --resume') + expect(wrapped.env).toEqual({ ORCA_AGENT_ENV: 'user-value' }) + expect(wrapped.wrappedSetupCommand).toBeUndefined() + // The real launch command is never moved into the sequenced env. + expect(wrapped.env).not.toHaveProperty(SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV) + }) + + it('carries the resolved launch command in the SPAWN env only when waiting for setup', () => { + const createSequenced = vi.fn(() => ({ + setupCommand: 'run-setup && touch marker', + startupCommand: 'wait-for marker; exec "$ORCA_SEQUENCED_STARTUP_COMMAND"', + startupEnv: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'claude --resume' } + })) + const wrapped = wrapAgentPlanWithSetupSequence(PLAN, SETUP, createSequenced) + + // createSequenced is fed the resolved launch command as the startup command. + expect(createSequenced).toHaveBeenCalledWith( + expect.objectContaining({ + runnerScriptPath: '/wt/.orca/setup.sh', + startupCommand: 'claude --resume' + }) + ) + // The spawned command is the wait-then-run wrapper, not the raw agent command. + expect(wrapped.command).toBe('wait-for marker; exec "$ORCA_SEQUENCED_STARTUP_COMMAND"') + expect(wrapped.command).not.toBe('claude --resume') + // The real launch command travels in the spawn env (this env is applied by the + // caller AFTER admission, so it never reaches the admitted snapshot). + expect(wrapped.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe('claude --resume') + // User agent env is preserved alongside the sequenced key. + expect(wrapped.env?.ORCA_AGENT_ENV).toBe('user-value') + expect(wrapped.wrappedSetupCommand).toBe('run-setup && touch marker') + }) +}) diff --git a/src/main/agent-launch/agent-launch-setup-sequence-wrap.ts b/src/main/agent-launch/agent-launch-setup-sequence-wrap.ts new file mode 100644 index 00000000000..f22aaa65b5d --- /dev/null +++ b/src/main/agent-launch/agent-launch-setup-sequence-wrap.ts @@ -0,0 +1,49 @@ +import { + createSequencedSetupAgentCommands, + type SequencedSetupAgentCommands +} from '../../shared/setup-agent-sequencing' +import { getSetupRunnerCommandPlatformForPath } from '../../shared/setup-runner-command' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { WorktreeSetupLaunch } from '../../shared/types' + +export type WrappedAgentSpawnCommand = { + command: string + env?: Record + wrappedSetupCommand?: string +} + +/** Wait-for-agent setup sequencing (#6298) for a resolved agent plan: when the + * setup runner requests it, the agent terminal waits on the setup marker, then + * runs the resolved launch command carried by the sequenced env. + * + * SECURITY: this MUST be applied AFTER admission, in the spawn path only. The + * sequenced env holds the real launch command, so it belongs to the spawned + * PTY's env and must never enter the admitted snapshot or a persisted failure — + * both are produced upstream from the resolved plan, before this wrap runs. */ +export function wrapAgentPlanWithSetupSequence( + plan: AgentStartupPlan, + setup: WorktreeSetupLaunch | undefined, + createSequenced: (args: { + runnerScriptPath: string + startupCommand: string + platform: ReturnType + }) => SequencedSetupAgentCommands = createSequencedSetupAgentCommands +): WrappedAgentSpawnCommand { + if (setup?.waitForAgentStartup !== true) { + return { command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}) } + } + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequenced({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: plan.launchCommand, + platform + }) + return { + command: sequenced.startupCommand, + env: { ...plan.env, ...sequenced.startupEnv }, + wrappedSetupCommand: sequenced.setupCommand + } +} diff --git a/src/main/agent-launch/agent-launch-spawn-dispatch.test.ts b/src/main/agent-launch/agent-launch-spawn-dispatch.test.ts new file mode 100644 index 00000000000..762110fd12a --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn-dispatch.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { dispatchAgentLaunchSpawn } from './agent-launch-spawn-dispatch' +import type { + AgentLaunchSpawnDeps, + AgentLaunchSpawnInput, + AgentLaunchSpawnTarget +} from './agent-launch-spawn' +import { AgentLaunchBoundary } from './agent-launch-boundary' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator +} from './agent-launch-admission-store' +import type { GlobalSettings } from '../../shared/types' +import type { + ResolvedAgentLaunch, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch' + +function makeSnapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['/opt/resolved-claude', '--tui'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function makeLaunch(): ResolvedAgentLaunch { + const snapshot = makeSnapshot() + return { + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + argv: snapshot.argv, + agentEnv: snapshot.agentEnv, + variables: { values: { repoPath: null, worktreePath: null }, referenced: [] }, + snapshot, + policy: { + intent: 'interactive', + mode: 'built-in', + client: 'desktop', + isRemote: false, + platform: 'linux', + promptInjectionMode: 'stdin-after-start', + expectedProcess: 'claude', + env: 'none' + }, + notices: [], + telemetry: { agentKind: 'claude-code', usedCustomAgent: false }, + admissionGuard: { fingerprint: 'fp-1', stableInputDigest: 'sfp-1', basis: 'explicit' } + } +} + +const TARGET: AgentLaunchSpawnTarget = { + platform: 'linux', + shell: 'posix', + isRemote: false, + executionHostId: 'local', + targetHomePath: '/home/dev' +} + +function makeDeps(outcome: () => ResolveAgentLaunchOutcome): { + deps: AgentLaunchSpawnDeps + store: AgentLaunchAdmissionStore + boundary: AgentLaunchBoundary +} { + const store = new AgentLaunchAdmissionStore() + const boundary = new AgentLaunchBoundary({ + admissionStore: store, + coordinator: new LaunchAdmissionCoordinator() + }) + return { + store, + boundary, + deps: { + getSettings: () => ({}) as GlobalSettings, + getCatalogRevision: () => 5, + boundary, + resolve: () => outcome() + } + } +} + +function baseInput(): AgentLaunchSpawnInput { + return { + request: { selection: { kind: 'agent', agent: 'claude' }, prompt: 'do the thing' }, + intent: { kind: 'interactive', client: 'desktop' }, + target: TARGET, + variables: { repoPath: '/repo', worktreePath: '/repo/wt' }, + scope: 'worktree-1', + principal: { kind: 'local' } + } +} + +describe('dispatchAgentLaunchSpawn', () => { + it('spawns exactly one PTY from the resolved command and settles registered', async () => { + const { deps, store } = makeDeps(() => ({ ok: true, launch: makeLaunch() })) + const spawn = vi.fn(async (plan, token) => { + // The plan command comes from host resolution, not any client input. + expect(plan.launchCommand).toContain('/opt/resolved-claude') + expect(token).toMatch(/.+/) + return { id: 'pty-1' } + }) + const result = await dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn }) + + expect(result.ok).toBe(true) + expect(spawn).toHaveBeenCalledTimes(1) + if (result.ok) { + expect(result.result).toEqual({ id: 'pty-1' }) + expect(result.receipt.launchToken).toBeTruthy() + } + // Registered settles the reservation (released from the pending store). + expect(store.pendingCount()).toBe(0) + }) + + it('creates zero PTYs on a typed resolution failure', async () => { + const { deps, store } = makeDeps(() => ({ + ok: false, + failure: { code: 'base_agent_unavailable', baseAgent: 'claude' } + })) + const spawn = vi.fn(async () => ({ id: 'pty-x' })) + const result = await dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn }) + + expect(result).toEqual({ + ok: false, + failure: { code: 'base_agent_unavailable', baseAgent: 'claude' } + }) + expect(spawn).not.toHaveBeenCalled() + expect(store.pendingCount()).toBe(0) + }) + + it('settles failed and rethrows when the spawn executor throws', async () => { + const { deps, store } = makeDeps(() => ({ ok: true, launch: makeLaunch() })) + const spawn = vi.fn(async () => { + throw new Error('spawn boom') + }) + await expect(dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn })).rejects.toThrow( + /spawn boom/ + ) + expect(spawn).toHaveBeenCalledTimes(1) + // Failed releases the reservation entirely; no leaked pending record. + expect(store.pendingCount()).toBe(0) + }) + + it('propagates a request error without spawning', async () => { + const { deps } = makeDeps(() => ({ + ok: false, + requestError: { code: 'untrusted_reference' } + })) + const spawn = vi.fn(async () => ({ id: 'pty-x' })) + const result = await dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn }) + expect(result).toEqual({ ok: false, requestError: { code: 'untrusted_reference' } }) + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent-launch/agent-launch-spawn-dispatch.ts b/src/main/agent-launch/agent-launch-spawn-dispatch.ts new file mode 100644 index 00000000000..451e6927033 --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn-dispatch.ts @@ -0,0 +1,57 @@ +// The resolve -> spawn -> settle sequencer every host launch surface shares (U3). +// A surface supplies the resolution inputs plus a `spawn` executor that creates +// and registers exactly ONE PTY from the resolved plan; this module runs the +// resolution through the host boundary, invokes the executor only on success, and +// settles the admission reservation ('registered' once the PTY is registered, +// 'failed' if the executor throws). A typed resolution failure/request error +// returns without ever calling the executor, so no PTY is created. Client-supplied +// command/env/launchConfig are irrelevant here: the plan comes only from +// resolveAgentLaunchSpawn's host resolution. + +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { + AgentLaunchFailure, + AgentLaunchReceipt, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import { + resolveAgentLaunchSpawn, + type AgentLaunchSpawnDeps, + type AgentLaunchSpawnInput +} from './agent-launch-spawn' + +/** Creates and registers exactly one PTY from the resolved plan. Must throw on + * spawn/registration failure so the reservation settles 'failed'; a returned + * value means the PTY is registered. */ +export type LaunchSpawnExecutor = (plan: AgentStartupPlan, launchToken: string) => Promise + +export type DispatchAgentLaunchArgs = { + deps: AgentLaunchSpawnDeps + input: AgentLaunchSpawnInput + spawn: LaunchSpawnExecutor +} + +export type DispatchAgentLaunchResult = + | { ok: true; result: R; receipt: AgentLaunchReceipt } + | { ok: false; failure: AgentLaunchFailure } + | { ok: false; requestError: AgentLaunchRequestError } + +/** Resolve, then spawn+settle exactly once. Rethrows an executor failure after + * settling 'failed' so the caller's existing spawn-error handling still runs. */ +export async function dispatchAgentLaunchSpawn( + args: DispatchAgentLaunchArgs +): Promise> { + const resolution = await resolveAgentLaunchSpawn(args.deps, args.input) + if (!resolution.ok) { + return resolution + } + const { plan, receipt } = resolution + try { + const result = await args.spawn(plan, receipt.launchToken) + args.deps.boundary.settleAgentLaunch(receipt.launchToken, 'registered') + return { ok: true, result, receipt } + } catch (err) { + args.deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed') + throw err + } +} diff --git a/src/main/agent-launch/agent-launch-spawn.test.ts b/src/main/agent-launch/agent-launch-spawn.test.ts new file mode 100644 index 00000000000..c19e93165d8 --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it, vi } from 'vitest' +import { + resolveAgentLaunchSpawn, + type AgentLaunchSpawnDeps, + type AgentLaunchSpawnInput, + type AgentLaunchSpawnTarget +} from './agent-launch-spawn' +import { AgentLaunchBoundary } from './agent-launch-boundary' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator +} from './agent-launch-admission-store' +import type { CustomTuiAgentId, GlobalSettings } from '../../shared/types' +import type { + ResolvedAgentLaunch, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchRequest } from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import { customId } from './agent-launch-test-catalog' + +function makeSnapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['/opt/resolved-claude', '--tui'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function makeLaunch(): ResolvedAgentLaunch { + const snapshot = makeSnapshot() + return { + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + argv: snapshot.argv, + agentEnv: snapshot.agentEnv, + variables: { values: { repoPath: null, worktreePath: null }, referenced: [] }, + snapshot, + policy: { + intent: 'interactive', + mode: 'built-in', + client: 'desktop', + isRemote: false, + platform: 'linux', + promptInjectionMode: 'stdin-after-start', + expectedProcess: 'claude', + env: 'none' + }, + notices: [], + telemetry: { agentKind: 'claude-code', usedCustomAgent: false }, + admissionGuard: { fingerprint: 'fp-1', stableInputDigest: 'sfp-1', basis: 'explicit' } + } +} + +const TARGET: AgentLaunchSpawnTarget = { + platform: 'linux', + shell: 'posix', + isRemote: false, + executionHostId: 'local', + targetHomePath: '/home/dev' +} + +function makeDeps( + resolve: (request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome +): AgentLaunchSpawnDeps { + return { + getSettings: () => ({}) as GlobalSettings, + getCatalogRevision: () => 7, + boundary: new AgentLaunchBoundary({ + admissionStore: new AgentLaunchAdmissionStore(), + coordinator: new LaunchAdmissionCoordinator() + }), + resolve: (request) => resolve(request) + } +} + +function baseInput(overrides: Partial = {}): AgentLaunchSpawnInput { + return { + request: { selection: { kind: 'agent', agent: 'claude' }, prompt: 'do the thing' }, + intent: { kind: 'interactive', client: 'desktop' }, + target: TARGET, + variables: { repoPath: '/repo', worktreePath: '/repo/wt' }, + scope: 'worktree-1', + principal: { kind: 'local' }, + ...overrides + } +} + +describe('resolveAgentLaunchSpawn', () => { + it('resolves the command from host state, never a client-supplied command/env', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + const result = await resolveAgentLaunchSpawn(deps, baseInput()) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + // The launch command comes from the resolved argv, not any client input. + expect(result.plan.launchCommand).toContain('/opt/resolved-claude') + expect(result.receipt.catalogRevision).toBe(7) + + const request = resolve.mock.calls[0]![0] + expect(request.selection).toEqual({ kind: 'agent', agent: 'claude' }) + expect(request.platform).toBe('linux') + expect(request.executionHostId).toBe('local') + expect(request.targetHomePath).toBe('/home/dev') + // The request is assembled only from host inputs; it has no command/env keys. + expect('command' in request).toBe(false) + expect('env' in request).toBe(false) + }) + + it('derives persisted default reference for a default selection', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ request: { selection: { kind: 'default' }, prompt: 'x' } }) + ) + expect(resolve.mock.calls[0]![0].reference).toEqual({ kind: 'persisted', owner: 'default' }) + }) + + it('resolves a source-control recipe id to its stored agentArgs as perLaunchArgs (U7)', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' } + }, + recipeRepo: { + sourceControlAi: { actionOverrides: { fixChecks: { agentArgs: '--recipe one' } } } + } + }) + ) + // The host reads recipe.agentArgs from settings and threads it; the client + // sent only the recipe id, never args. + expect(resolve.mock.calls[0]![0].perLaunchArgs).toBe('--recipe one') + expect(resolve.mock.calls[0]![0].reference).toEqual({ + kind: 'persisted', + owner: 'source-control-recipe' + }) + }) + + it('rejects an unknown recipe action id with untrusted_reference and never resolves (U7)', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + const result = await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + sourceRecord: { owner: 'source-control-recipe', id: 'not-a-real-action' } + } + }) + ) + expect(result).toEqual({ ok: false, requestError: { code: 'untrusted_reference' } }) + expect(resolve).not.toHaveBeenCalled() + }) + + it('leaves perLaunchArgs unset for a non-recipe sourceRecord (U7)', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + sourceRecord: { owner: 'quick-command', id: 'qc-1' } + } + }) + ) + expect('perLaunchArgs' in resolve.mock.calls[0]![0]).toBe(false) + }) + + it('derives live-selection reference for a bare agent selection', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn(deps, baseInput()) + expect(resolve.mock.calls[0]![0].reference).toEqual({ kind: 'live-selection' }) + }) + + it('derives a persisted owner reference from a validated source record', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + sourceRecord: { owner: 'session', id: 's-1' } + } + }) + ) + expect(resolve.mock.calls[0]![0].reference).toEqual({ kind: 'persisted', owner: 'session' }) + }) + + it('propagates a typed resolution failure without a plan', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: false as const, + failure: { code: 'base_agent_unavailable' as const, baseAgent: 'claude' as const } + })) + const deps = makeDeps(resolve) + const result = await resolveAgentLaunchSpawn(deps, baseInput()) + expect(result).toEqual({ + ok: false, + failure: { code: 'base_agent_unavailable', baseAgent: 'claude' } + }) + }) +}) + +// M-1 / plan §1364: Source Control AI runs the same custom-agent launch for a +// GitHub, a GitLab, and a generic (non-GitHub/GitLab) review fixture. The +// provider adapter supplies task text/URL (commandInputTemplate); it must not +// reinterpret the agent id or assemble its command — recipe resolution reads +// only agentArgs, so the launch is provider-neutral by construction. +describe('Source Control AI custom-agent launch is provider-neutral (M-1, §1364)', () => { + const REVIEW_ACTION = 'resolveComments' + const CUSTOM: CustomTuiAgentId = customId('claude', '00000000-0000-4000-8000-0000000000c1') + + const PROVIDER_FIXTURES = [ + { name: 'GitHub', template: 'GitHub PR review: https://github.com/acme/app/pull/12' }, + { + name: 'GitLab', + template: 'GitLab MR review: https://gitlab.com/acme/app/-/merge_requests/34' + }, + { + name: 'Gitea (generic non-GitHub/GitLab)', + template: 'Gitea review: https://gitea.example.com/acme/app/pulls/7' + } + ] as const + + // Each provider configures the SAME custom-agent recipe args on the review + // action but a DIFFERENT provider task-text template. Returns the resolver + // request the host assembled. + async function resolvedRequestFor(template: string): Promise { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: CUSTOM }, + prompt: 'x', + sourceRecord: { owner: 'source-control-recipe', id: REVIEW_ACTION } + }, + recipeRepo: { + sourceControlAi: { + actionOverrides: { + [REVIEW_ACTION]: { agentArgs: '--review one', commandInputTemplate: template } + } + } + } + }) + ) + return resolve.mock.calls[0]![0] + } + + for (const fixture of PROVIDER_FIXTURES) { + it(`${fixture.name}: threads the identical recipe args and preserves the custom agent id`, async () => { + const request = await resolvedRequestFor(fixture.template) + expect(request.perLaunchArgs).toBe('--review one') + expect(request.selection).toEqual({ kind: 'agent', agent: CUSTOM }) + expect(request.reference).toEqual({ kind: 'persisted', owner: 'source-control-recipe' }) + // The provider's task text/URL never enters the resolved launch args. + expect(request.perLaunchArgs).not.toMatch(/https?:|github|gitlab|gitea/i) + }) + } + + it('all three providers resolve byte-identical launch args and agent identity', async () => { + const [gh, gl, generic] = await Promise.all( + PROVIDER_FIXTURES.map((fixture) => resolvedRequestFor(fixture.template)) + ) + expect(gh.perLaunchArgs).toBe(gl.perLaunchArgs) + expect(gl.perLaunchArgs).toBe(generic.perLaunchArgs) + expect(gh.selection).toEqual(generic.selection) + expect(gh.reference).toEqual(generic.reference) + }) +}) diff --git a/src/main/agent-launch/agent-launch-spawn.ts b/src/main/agent-launch/agent-launch-spawn.ts new file mode 100644 index 00000000000..098968cfdde --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn.ts @@ -0,0 +1,222 @@ +// Host adapter that turns a client `agentLaunch` request into a resolved startup +// plan + receipt through the launch boundary (U3). The client request names only +// the agent identity and prompt: this module builds the ResolveAgentLaunchRequest +// entirely from HOST state (settings, normalized catalog, detection, derived +// target) and NEVER reads a client command/launchConfig/launchAgent/env — those +// fields have no representation in AgentLaunchSpawnInput. Intent is constructed +// host-side; the reference authority is derived here, not copied from the client. + +import type { GlobalSettings, BuiltInTuiAgent, Repo } from '../../shared/types' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { + AgentLaunchReceipt, + AgentLaunchFailure, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import type { + AgentLaunchExecutionHostId, + AgentLaunchSnapshot, + AgentReferenceAuthority, + LaunchIntent, + ResolvedAgentLaunch +} from '../../shared/agent-launch-host-contract' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request' +import { isSourceControlActionId } from '../../shared/source-control-ai-actions' +import { resolveSourceControlActionRecipe } from '../../shared/source-control-ai' +import { normalizeCatalogFromSettings } from './agent-catalog-projections' +import { STARTUP_COMMAND_TEXT_MAX_CHARS } from '../providers/windows-shell-args' +import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import type { + AgentLaunchBoundary, + HostStateResolution, + ResolveAgentLaunchPlanResult +} from './agent-launch-boundary' +import type { AdmissionPrincipal } from './agent-launch-admission-store' + +export type AgentLaunchSpawnTarget = { + platform: NodeJS.Platform + shell?: AgentStartupShell + isRemote: boolean + executionHostId: AgentLaunchExecutionHostId + targetHomePath?: string | null + /** null = detection unavailable (unknown); never claims "not installed". */ + detectedStockBaseAgents?: ReadonlySet | null + transportConfidentialityAvailable?: boolean +} + +export type AgentLaunchSpawnDeps = { + getSettings: () => GlobalSettings + getCatalogRevision: () => number + boundary: AgentLaunchBoundary + preflight?: (launch: ResolvedAgentLaunch) => Promise | void + prepareEnv?: (launch: ResolvedAgentLaunch) => Promise | void + /** Injectable for tests; defaults to the real total resolver. */ + resolve?: typeof resolveAgentLaunch +} + +export type AgentLaunchSpawnInput = { + request: AgentLaunchSpawnRequest + intent: LaunchIntent + target: AgentLaunchSpawnTarget + variables: { repoPath?: string | null; worktreePath?: string | null } + /** Host-trusted repo overrides for a source-control-recipe sourceRecord lookup + * (U7). Derived from the launch's worktree context, never client-supplied; + * absent falls back to the global recipe. */ + recipeRepo?: Pick | null + scope: string + principal: AdmissionPrincipal + persistedSnapshot?: AgentLaunchSnapshot + /** Provider session for a resume/fork replay; drives the resolver's resume-argv + * append. Only the resume ingestion sets it. */ + resumeProviderSession?: AgentProviderSessionMetadata +} + +export type AgentLaunchSpawnResolution = + | { ok: true; plan: AgentStartupPlan; receipt: AgentLaunchReceipt } + | { ok: false; failure: AgentLaunchFailure } + | { ok: false; requestError: AgentLaunchRequestError } + +/** Derive the reference authority host-side from the requested selection and any + * host-verified saved owner. A live selection cannot forge persisted fallback + * authority; that requires a validated sourceRecord owner. */ +function referenceFor(request: AgentLaunchSpawnRequest): AgentReferenceAuthority { + if (request.selection.kind === 'default') { + return { kind: 'persisted', owner: 'default' } + } + if (request.sourceRecord) { + return { kind: 'persisted', owner: request.sourceRecord.owner } + } + return { kind: 'live-selection' } +} + +/** Resolve the host-owned per-launch args for a validated sourceRecord (U7). Only + * a source-control-recipe owner contributes args today: the host validates the id + * is a real action id (unknown/mismatched → untrusted_reference, no PTY), then + * reads the recipe's stored agentArgs from repo-scoped settings (global fallback + * when the repo id is absent). Clients never send args — only the recipe id. */ +function resolvePerLaunchArgs( + request: AgentLaunchSpawnRequest, + recipeRepo: Pick | null | undefined, + settings: GlobalSettings +): { ok: true; perLaunchArgs?: string } | { ok: false; requestError: AgentLaunchRequestError } { + const sourceRecord = request.sourceRecord + if (!sourceRecord || sourceRecord.owner !== 'source-control-recipe') { + return { ok: true } + } + if (!sourceRecord.id || !isSourceControlActionId(sourceRecord.id)) { + return { ok: false, requestError: { code: 'untrusted_reference' } } + } + const recipe = resolveSourceControlActionRecipe({ + settings, + repo: recipeRepo, + actionId: sourceRecord.id + }) + return recipe.agentArgs !== undefined + ? { ok: true, perLaunchArgs: recipe.agentArgs } + : { ok: true } +} + +/** Build the boundary's `resolve` closure from the surface deps + input. Each + * call re-reads live settings and the normalized catalog and runs the total + * resolver over the fixed request; it does no async I/O, so the boundary can + * re-invoke it inside the admission coordinator. Shared by the single-shot + * spawn path and U4's two-stage worktree transaction so both surfaces produce + * one canonical serialization/fingerprint. */ +export function buildHostStateResolve( + deps: AgentLaunchSpawnDeps, + input: AgentLaunchSpawnInput +): () => HostStateResolution { + const resolveFn = deps.resolve ?? resolveAgentLaunch + const reference = referenceFor(input.request) + return (): HostStateResolution => { + const settings = deps.getSettings() + const perLaunch = resolvePerLaunchArgs(input.request, input.recipeRepo, settings) + if (!perLaunch.ok) { + return { + outcome: { ok: false, requestError: perLaunch.requestError }, + catalogRevision: deps.getCatalogRevision() + } + } + const catalog = normalizeCatalogFromSettings(settings) + const outcome: ResolveAgentLaunchOutcome = resolveFn( + { + selection: input.request.selection, + intent: input.intent, + reference, + variables: input.variables, + ...(perLaunch.perLaunchArgs !== undefined + ? { perLaunchArgs: perLaunch.perLaunchArgs } + : {}), + platform: input.target.platform, + ...(input.target.shell ? { shell: input.target.shell } : {}), + isRemote: input.target.isRemote, + targetHomePath: input.target.targetHomePath ?? null, + detectedStockBaseAgents: input.target.detectedStockBaseAgents ?? null, + executionHostId: input.target.executionHostId, + ...(input.target.transportConfidentialityAvailable !== undefined + ? { transportConfidentialityAvailable: input.target.transportConfidentialityAvailable } + : {}), + ...(input.persistedSnapshot ? { persistedSnapshot: input.persistedSnapshot } : {}), + ...(input.resumeProviderSession + ? { resumeProviderSession: input.resumeProviderSession } + : {}) + }, + catalog, + settings + ) + return { outcome, catalogRevision: deps.getCatalogRevision() } + } +} + +/** Resolve a legacy renderer-spawned startup request into a plan WITHOUT taking + * an admission token. Reuses the exact host-state resolve closure the admitted + * path builds, so the two share one serialization/fingerprint, but stops before + * admission because this path registers no terminal receipt (no settle seam) and + * a held token would leak capacity. One-release compatibility shim; removed with + * the startupAgent/startupDraft fields. */ +export function resolveAgentLaunchStartupPlanWithoutAdmission( + deps: AgentLaunchSpawnDeps, + input: AgentLaunchSpawnInput +): ResolveAgentLaunchPlanResult { + const resolve = buildHostStateResolve(deps, input) + return deps.boundary.resolveAgentLaunchPlanWithoutAdmission({ + resolve, + prompt: input.request.prompt ?? '', + ...(input.request.allowEmptyPromptLaunch !== undefined + ? { allowEmptyPromptLaunch: input.request.allowEmptyPromptLaunch } + : {}), + ...(input.request.promptDelivery !== undefined + ? { promptDelivery: input.request.promptDelivery } + : {}), + maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS + }) +} + +/** Resolve a client agentLaunch request into a startup plan + receipt, or a + * typed failure/request-error. Creates no PTY: the caller owns spawning. */ +export async function resolveAgentLaunchSpawn( + deps: AgentLaunchSpawnDeps, + input: AgentLaunchSpawnInput +): Promise { + const resolve = buildHostStateResolve(deps, input) + + return deps.boundary.executeAgentLaunch({ + scope: input.scope, + principal: input.principal, + resolve, + prompt: input.request.prompt ?? '', + ...(input.request.allowEmptyPromptLaunch !== undefined + ? { allowEmptyPromptLaunch: input.request.allowEmptyPromptLaunch } + : {}), + ...(input.request.promptDelivery !== undefined + ? { promptDelivery: input.request.promptDelivery } + : {}), + // The shared plan builder is main-free, so the provider size ceiling is + // threaded here rather than imported there. + maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS, + ...(deps.preflight ? { preflight: deps.preflight } : {}), + ...(deps.prepareEnv ? { prepareEnv: deps.prepareEnv } : {}) + }) +} diff --git a/src/main/agent-launch/agent-launch-test-catalog.ts b/src/main/agent-launch/agent-launch-test-catalog.ts new file mode 100644 index 00000000000..564da383492 --- /dev/null +++ b/src/main/agent-launch/agent-launch-test-catalog.ts @@ -0,0 +1,102 @@ +// Test fixtures for the agent-launch resolver: catalog/settings/request builders +// shared across the lifecycle, assembly, and env suites. Not a test file. + +import type { + BuiltInTuiAgent, + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + GlobalSettings, + TuiAgent +} from '../../shared/types' +import { normalizeAgentCatalog, type AgentCatalog } from '../../shared/agent-catalog-normalization' +import type { + AgentLaunchExecutionHostId, + AgentReferenceAuthority, + LaunchIntent, + ResolveAgentLaunchRequest +} from '../../shared/agent-launch-host-contract' + +let uuidCounter = 0 +function nextUuid(): string { + uuidCounter += 1 + const hex = uuidCounter.toString(16).padStart(12, '0') + return `00000000-0000-4000-8000-${hex}` +} + +export function customId(base: BuiltInTuiAgent, suffix?: string): CustomTuiAgentId { + return `custom-agent:${base}:${suffix ?? nextUuid()}` +} + +export function customAgent( + overrides: Partial & { id: CustomTuiAgentId } +): CustomTuiAgent { + return { + baseAgent: 'claude', + label: 'My Agent', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +export function tombstone( + overrides: Partial & { id: CustomTuiAgentId } +): DeletedCustomTuiAgent { + return { baseAgent: 'claude', label: 'Deleted Agent', deletedAt: 1, ...overrides } +} + +export function catalogOf(input: { + customTuiAgents?: CustomTuiAgent[] + deletedCustomTuiAgents?: DeletedCustomTuiAgent[] + disabledTuiAgents?: TuiAgent[] + defaultTuiAgent?: TuiAgent | 'auto' | 'blank' | null +}): AgentCatalog { + return normalizeAgentCatalog({ + customTuiAgents: input.customTuiAgents ?? [], + deletedCustomTuiAgents: input.deletedCustomTuiAgents ?? [], + disabledTuiAgents: input.disabledTuiAgents ?? [], + // Preserve an explicit null (repair-needed default); only absent means auto. + defaultTuiAgent: 'defaultTuiAgent' in input ? input.defaultTuiAgent : 'auto' + }).catalog +} + +export function settingsOf(overrides?: { + agentCmdOverrides?: Partial> + agentDefaultArgs?: Partial> + agentDefaultEnv?: Partial>> +}): GlobalSettings { + return { + agentCmdOverrides: overrides?.agentCmdOverrides ?? {}, + agentDefaultArgs: overrides?.agentDefaultArgs ?? {}, + agentDefaultEnv: overrides?.agentDefaultEnv ?? {} + } as unknown as GlobalSettings +} + +export const INTERACTIVE_DESKTOP: LaunchIntent = { kind: 'interactive', client: 'desktop' } +export const PERSISTED_DEFAULT: AgentReferenceAuthority = { kind: 'persisted', owner: 'default' } +export const LIVE_SELECTION: AgentReferenceAuthority = { kind: 'live-selection' } + +export function requestOf( + overrides: Partial & { + selection: ResolveAgentLaunchRequest['selection'] + } +): ResolveAgentLaunchRequest { + return { + intent: INTERACTIVE_DESKTOP, + reference: LIVE_SELECTION, + variables: {}, + platform: 'linux', + isRemote: false, + targetHomePath: '/home/dev', + detectedStockBaseAgents: null, + executionHostId: 'local' as AgentLaunchExecutionHostId, + ...overrides + } +} + +/** All base built-ins detected — a concrete non-empty detection set. */ +export function allDetected(...agents: BuiltInTuiAgent[]): ReadonlySet { + return new Set(agents) +} diff --git a/src/main/agent-launch/agent-launch-vault-resume.test.ts b/src/main/agent-launch/agent-launch-vault-resume.test.ts new file mode 100644 index 00000000000..cb08b7a288c --- /dev/null +++ b/src/main/agent-launch/agent-launch-vault-resume.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest' +import { + buildVaultResumeStartup, + findVaultResumeSession, + resolveRevalidatedVaultResume, + resolveRevalidatedVaultResumeDetails, + resolveVaultResumeCopyCommand, + resolveVaultResumeSpawn, + type VaultResumeSession +} from './agent-launch-vault-resume' +import { RESUMABLE_TUI_AGENTS } from '../../shared/agent-session-resume' +import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentLaunchVaultResumeEntry } from '../../shared/agent-launch-spawn-request' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { AgentSessionRecordStore } from './agent-session-record-store' + +const CUSTOM_CODEX_ID = 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as const + +// Agents that are both AI Vault sessions AND resumable providers take the +// structured startup-plan branch; the rest (e.g. OMP) fall through to the +// path-based resume command. G5 requires every resumable provider to be proven. +const RESUMABLE_VAULT_AGENTS = AI_VAULT_AGENTS.filter((agent) => + (RESUMABLE_TUI_AGENTS as readonly string[]).includes(agent) +) + +function vaultSession(overrides: Partial = {}): VaultResumeSession { + return { + agent: 'codex', + sessionId: 'sess-abc-123', + cwd: '/repo/app', + codexHome: null, + executionHostId: LOCAL_EXECUTION_HOST_ID, + ...overrides + } +} + +function entryFor(session: VaultResumeSession): AgentLaunchVaultResumeEntry { + return { + executionHostId: session.executionHostId, + agent: session.agent, + sessionId: session.sessionId + } +} + +describe('findVaultResumeSession', () => { + it('matches on executionHostId, agent, and sessionId', () => { + const target = vaultSession({ sessionId: 'match-me' }) + const sessions = [vaultSession({ sessionId: 'other' }), target] + expect(findVaultResumeSession(entryFor(target), sessions)).toBe(target) + }) + + it('returns null when any identity field differs', () => { + const target = vaultSession({ sessionId: 'match-me', agent: 'codex' }) + const sessions = [target] + expect(findVaultResumeSession({ ...entryFor(target), sessionId: 'nope' }, sessions)).toBeNull() + expect(findVaultResumeSession({ ...entryFor(target), agent: 'claude' }, sessions)).toBeNull() + expect( + findVaultResumeSession({ ...entryFor(target), executionHostId: 'ssh:box' }, sessions) + ).toBeNull() + }) + + it('ignores the client-echoed filePath entirely (host re-derives identity)', () => { + const target = vaultSession({ agent: 'omp', filePath: '/host/derived.jsonl' }) + // A client sending a bogus filePath still matches on the three identity + // fields and the assembly reads the host-discovered filePath, never this one. + const entry: AgentLaunchVaultResumeEntry = { + ...entryFor(target), + filePath: '/attacker/controlled.jsonl' + } + expect(findVaultResumeSession(entry, [target])).toBe(target) + }) + + it('uses the locator to distinguish duplicate legacy identities', () => { + const first = vaultSession({ sessionId: 'same', resumeLocator: 'a'.repeat(64) }) + const second = vaultSession({ sessionId: 'same', resumeLocator: 'b'.repeat(64) }) + expect( + findVaultResumeSession({ ...entryFor(first), resumeLocator: second.resumeLocator }, [ + first, + second + ]) + ).toBe(second) + expect(findVaultResumeSession(entryFor(first), [first, second])).toBeNull() + }) +}) + +function capturedSnapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: CUSTOM_CODEX_ID, + baseAgent: 'codex', + displayLabel: 'Codex Sol', + mode: 'custom', + argv: ['codex', '--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +describe('resolveRevalidatedVaultResume', () => { + it('converts one correlated owner into the ordinary session resume request', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([ + { + worktreeId: 'wt-source', + requestedAgent: CUSTOM_CODEX_ID, + baseAgent: 'codex', + providerSession: { + key: 'session_id', + id: 'hook-resume-id', + transcriptPath: '/repo/transcript.jsonl' + }, + launchSnapshot: capturedSnapshot(), + registeredAt: 1, + updatedAt: 1 + } + ]) + expect( + resolveRevalidatedVaultResume({ + session: vaultSession({ + sessionId: 'scanner-id', + filePath: '/repo/transcript.jsonl' + }), + sessionRecordStore: store, + targetExecutionHostId: 'local', + targetPlatform: 'linux', + preferredWorktreeId: 'wt-destination' + }) + ).toEqual({ + kind: 'snapshot', + request: { + resume: { + operation: 'resume', + sessionKey: { + worktreeId: 'wt-source', + baseAgent: 'codex', + providerSessionId: 'hook-resume-id' + } + } + } + }) + }) + + it('builds a disclosed current-settings fallback only for resumable providers', () => { + const store = new AgentSessionRecordStore() + const fallback = resolveRevalidatedVaultResume({ + session: vaultSession(), + sessionRecordStore: store, + targetExecutionHostId: 'local', + targetPlatform: 'linux', + mintNoticeToken: () => 'notice-token' + }) + expect(fallback).toMatchObject({ + kind: 'fallback', + reason: 'missing', + launchNotices: { + launchToken: 'notice-token', + notices: [{ code: 'vault_original_config_unavailable', baseAgent: 'codex' }] + } + }) + const unsupported = resolveRevalidatedVaultResume({ + session: vaultSession({ agent: 'omp' }), + sessionRecordStore: store, + targetExecutionHostId: 'local', + targetPlatform: 'linux' + }) + expect(unsupported.kind).toBe('fallback') + if (unsupported.kind === 'fallback') { + expect(unsupported.reason).toBe('unsupported') + expect(unsupported.launchNotices).toBeUndefined() + } + }) +}) + +describe('resolveRevalidatedVaultResumeDetails', () => { + it('returns the captured argument suffix, including the original effort setting', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([ + { + worktreeId: 'wt-source', + requestedAgent: CUSTOM_CODEX_ID, + baseAgent: 'codex', + providerSession: { key: 'session_id', id: 'sess-abc-123' }, + launchSnapshot: capturedSnapshot(), + registeredAt: 1, + updatedAt: 1 + } + ]) + + expect( + resolveRevalidatedVaultResumeDetails({ session: vaultSession(), sessionRecordStore: store }) + ).toEqual({ + status: 'ok', + args: ['--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'] + }) + }) + + it('does not substitute current settings for missing private correlation', () => { + expect( + resolveRevalidatedVaultResumeDetails({ + session: vaultSession(), + sessionRecordStore: new AgentSessionRecordStore() + }) + ).toEqual({ status: 'unavailable' }) + }) +}) + +describe('buildVaultResumeStartup', () => { + it('appends the provider resume argv exactly once for every resumable vault agent', () => { + for (const agent of RESUMABLE_VAULT_AGENTS) { + const session = vaultSession({ agent: agent as AiVaultAgent, sessionId: `id-${agent}` }) + const startup = buildVaultResumeStartup({ session, hostPlatform: 'linux' }) + expect(startup.command).toContain(`id-${agent}`) + // The session id is the resume target and must appear exactly once. + expect(startup.command.split(`id-${agent}`).length - 1).toBe(1) + expect(startup.launchConfig).toBeDefined() + // The queued command re-enters the session's cwd before launching. + expect(startup.command).toContain('/repo/app') + } + }) + + it('resumes OMP by its host-derived transcript path, not the client field', () => { + const session = vaultSession({ + agent: 'omp', + sessionId: 'omp-sess', + filePath: '/host/transcripts/omp-sess.jsonl' + }) + const startup = buildVaultResumeStartup({ session, hostPlatform: 'linux' }) + // OMP is non-resumable → the path-based fallback resumes by absolute path. + expect(startup.command).toContain('/host/transcripts/omp-sess.jsonl') + expect(startup.launchConfig).toBeUndefined() + }) + + it('replays a remote session command verbatim without re-deriving it', () => { + const session = vaultSession({ + agent: 'codex', + executionHostId: 'ssh:box', + executionHostPlatform: 'linux', + resumeCommand: 'REMOTE_READY_COMMAND --resume remote-id' + }) + const startup = buildVaultResumeStartup({ session, hostPlatform: 'darwin' }) + expect(startup.command).toBe('REMOTE_READY_COMMAND --resume remote-id') + expect(startup.launchConfig).toBeUndefined() + expect(startup.env).toBeUndefined() + }) + + it('rewrites a WSL UNC Codex home to POSIX when the target is linux', () => { + const session = vaultSession({ + agent: 'codex', + codexHome: '\\\\wsl$\\Ubuntu\\home\\me\\.codex' + }) + const startup = buildVaultResumeStartup({ session, hostPlatform: 'linux' }) + expect(startup.command).toContain('/home/me/.codex') + expect(startup.command).not.toContain('wsl$') + }) + + it('honors a per-agent command override', () => { + const session = vaultSession({ agent: 'codex', sessionId: 'ov-id' }) + const startup = buildVaultResumeStartup({ + session, + hostPlatform: 'linux', + settings: { agentCmdOverrides: { codex: 'my-codex' } } + }) + expect(startup.command).toContain('my-codex') + }) +}) + +describe('resolveVaultResumeCopyCommand', () => { + it('returns the assembled command for a discovered entry', () => { + const session = vaultSession({ agent: 'codex', sessionId: 'copy-id' }) + const result = resolveVaultResumeCopyCommand({ + entry: entryFor(session), + sessions: [session], + hostPlatform: 'linux' + }) + expect(result.status).toBe('ok') + if (result.status === 'ok') { + expect(result.command).toBe( + buildVaultResumeStartup({ session, hostPlatform: 'linux' }).command + ) + } + }) + + it('fails closed with invalid_launch_snapshot when the host did not discover the entry', () => { + const session = vaultSession({ sessionId: 'known' }) + const result = resolveVaultResumeCopyCommand({ + entry: { ...entryFor(session), sessionId: 'unknown' }, + sessions: [session], + hostPlatform: 'linux' + }) + expect(result).toEqual({ + status: 'failed', + failure: { code: 'invalid_launch_snapshot' } + }) + }) +}) + +describe('resolveVaultResumeSpawn (U7 runtime resume-via-arm)', () => { + it('assembles the full startup (command/env/launchConfig) for a discovered resume', () => { + const session = vaultSession({ agent: 'codex', sessionId: 'spawn-id' }) + const result = resolveVaultResumeSpawn({ + vaultResume: { operation: 'resume', entry: entryFor(session) }, + sessions: [session], + hostPlatform: 'linux' + }) + expect(result.status).toBe('ok') + if (result.status === 'ok') { + const expected = buildVaultResumeStartup({ session, hostPlatform: 'linux' }) + expect(result.startup.command).toBe(expected.command) + expect(result.startup.launchConfig).toEqual(expected.launchConfig) + } + }) + + it('fails closed for an entry the host did not discover', () => { + const session = vaultSession({ sessionId: 'known' }) + const result = resolveVaultResumeSpawn({ + vaultResume: { operation: 'resume', entry: { ...entryFor(session), sessionId: 'unknown' } }, + sessions: [session], + hostPlatform: 'linux' + }) + expect(result).toEqual({ status: 'failed', failure: { code: 'invalid_launch_snapshot' } }) + }) + + it('fails closed for a copy op reaching the spawn arm (misroute)', () => { + // copy is served by the dedicated command method; a copy op must never spawn. + const session = vaultSession({ sessionId: 'copy-misroute' }) + const result = resolveVaultResumeSpawn({ + vaultResume: { operation: 'copy', entry: entryFor(session) }, + sessions: [session], + hostPlatform: 'linux' + }) + expect(result).toEqual({ status: 'failed', failure: { code: 'invalid_launch_snapshot' } }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-vault-resume.ts b/src/main/agent-launch/agent-launch-vault-resume.ts new file mode 100644 index 00000000000..18ac894f3ef --- /dev/null +++ b/src/main/agent-launch/agent-launch-vault-resume.ts @@ -0,0 +1,320 @@ +// Host-side AI Vault resume assembly (U5 FULL PORT of the renderer's +// buildAiVaultResumeStartupForWorktree). The client only echoes a discovered +// entry's identity; the host re-validates it against its OWN fresh discovery and +// rebuilds the resume command here, bypassing the resolver like legacy opaque +// replay (no admission token/receipt). The renderer helper deliberately encodes +// semantics the structured resolver does not model — remote-verbatim resume, +// OMP absolute-transcript resume, and WSL Codex-home rewrite — so this is a +// faithful replication, not a re-derivation. +// +// The only renderer-specific piece dropped in the port is the AppState platform +// heuristic (WSL/workspace probing): the host already knows the spawning target +// platform, and a session may only resume on a target matching its own host, so +// a non-local entry uses the discovered host platform and a local one uses the +// spawning host's platform directly. + +import { randomUUID } from 'node:crypto' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import type { PersistedLaunchNoticeState } from '../../shared/agent-launch-contract' +import { + buildAiVaultResumeCommand, + buildAiVaultResumeShellCommand +} from '../../shared/ai-vault-resume-command' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + isResumableTuiAgent, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import type { TuiAgent } from '../../shared/types' +import type { + AgentLaunchResumeRequest, + AgentLaunchVaultResumeDetailsResult, + AgentLaunchVaultResumeCopyResult, + AgentLaunchVaultResumeEntry +} from '../../shared/agent-launch-spawn-request' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +/** Re-exported for host callers that assemble the copy result. */ +export type VaultResumeCopyResult = AgentLaunchVaultResumeCopyResult +export type VaultResumeDetailsResult = AgentLaunchVaultResumeDetailsResult + +/** The fresh discovery slice the assembly reads. Sourced from the host's own + * `listAiVaultSessions`, never from the client — the client's echoed identity is + * only used to look this up (its `filePath` is ignored and re-derived here). */ +export type VaultResumeSession = Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'cwd' | 'codexHome' | 'executionHostId' +> & + Partial< + Pick + > + +/** Host settings the assembly reads. Built-in-keyed records are assignable to the + * wider TuiAgent-keyed helper params (all keys optional). */ +export type VaultResumeAssemblySettings = { + agentCmdOverrides?: Partial> + agentDefaultArgs?: Partial> + agentDefaultEnv?: Partial>> + terminalWindowsShell?: string +} + +export type VaultResumeStartup = { + command: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig +} + +/** Re-validate the client-echoed entry against the host's OWN fresh discovery. + * New entries match their opaque locator exactly. Legacy entries without one + * are accepted only when the three-field identity has one fresh match. */ +export function findVaultResumeSession( + entry: AgentLaunchVaultResumeEntry, + sessions: readonly S[] +): S | null { + const identityMatches = sessions.filter( + (session) => + session.executionHostId === entry.executionHostId && + session.agent === entry.agent && + session.sessionId === entry.sessionId + ) + const matches = entry.resumeLocator + ? identityMatches.filter((session) => session.resumeLocator === entry.resumeLocator) + : identityMatches + return matches.length === 1 ? matches[0] : null +} + +/** Re-validate + assemble the copyable resume command for a client-echoed entry. + * Shared by the desktop IPC and runtime RPC copy surfaces; the caller supplies + * its own fresh discovery and the spawning host platform. */ +export function resolveVaultResumeCopyCommand(args: { + entry: AgentLaunchVaultResumeEntry + sessions: readonly VaultResumeSession[] + hostPlatform: NodeJS.Platform + settings?: VaultResumeAssemblySettings +}): VaultResumeCopyResult { + const session = findVaultResumeSession(args.entry, args.sessions) + if (!session) { + return { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + } + return { + status: 'ok', + command: buildVaultResumeStartup({ + session, + hostPlatform: args.hostPlatform, + settings: args.settings + }).command + } +} + +export type VaultResumeSpawnResult = + | { status: 'ok'; startup: VaultResumeStartup } + | { status: 'failed'; failure: { code: 'invalid_launch_snapshot' } } + +/** Re-validate + assemble a vault resume SPAWN (as distinct from copy). A `copy` + * operation is served by the dedicated command method, so reaching here is a + * misroute; an entry the fresh scan does not contain fails closed. Both failures + * are invalid_launch_snapshot — no terminal, no client path becomes a spawn input. */ +export function resolveVaultResumeSpawn(args: { + vaultResume: { operation: 'resume' | 'copy'; entry: AgentLaunchVaultResumeEntry } + sessions: readonly VaultResumeSession[] + hostPlatform: NodeJS.Platform + settings?: VaultResumeAssemblySettings +}): VaultResumeSpawnResult { + if (args.vaultResume.operation !== 'resume') { + return { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + } + const session = findVaultResumeSession(args.vaultResume.entry, args.sessions) + if (!session) { + return { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + } + return { + status: 'ok', + startup: buildVaultResumeStartup({ + session, + hostPlatform: args.hostPlatform, + settings: args.settings + }) + } +} + +export type RevalidatedVaultResumeResolution = + | { kind: 'snapshot'; request: AgentLaunchResumeRequest } + | { + kind: 'fallback' + reason: 'missing' | 'ambiguous' | 'unsupported' + startup: VaultResumeStartup + launchNotices?: PersistedLaunchNoticeState + } + +/** Decide snapshot replay versus the disclosed current-settings fallback for an + * already fresh-scan-validated row. Both desktop and runtime callers use this + * exact correlation policy; only their scan and spawn mechanics differ. */ +export function resolveRevalidatedVaultResume(args: { + session: VaultResumeSession + sessionRecordStore: AgentSessionRecordStore + targetExecutionHostId: AgentLaunchExecutionHostId + targetPlatform: NodeJS.Platform + preferredWorktreeId?: string | null + settings?: VaultResumeAssemblySettings + mintNoticeToken?: () => string +}): RevalidatedVaultResumeResolution { + if (isResumableTuiAgent(args.session.agent)) { + const owner = args.sessionRecordStore.resolveVaultSnapshotOwner({ + baseAgent: args.session.agent, + scannedProviderSessionId: args.session.sessionId, + scannedTranscriptPath: args.session.filePath, + targetExecutionHostId: args.targetExecutionHostId, + targetPlatform: args.targetPlatform, + preferredWorktreeId: args.preferredWorktreeId + }) + if (owner.kind === 'found') { + return { + kind: 'snapshot', + request: { resume: { operation: 'resume', sessionKey: owner.sessionKey } } + } + } + return { + kind: 'fallback', + reason: owner.kind, + startup: buildVaultResumeStartup({ + session: args.session, + hostPlatform: args.targetPlatform, + settings: args.settings + }), + launchNotices: { + launchToken: (args.mintNoticeToken ?? randomUUID)(), + notices: [ + { + code: 'vault_original_config_unavailable', + baseAgent: args.session.agent + } + ] + } + } + } + + return { + kind: 'fallback', + reason: 'unsupported', + startup: buildVaultResumeStartup({ + session: args.session, + hostPlatform: args.targetPlatform, + settings: args.settings + }) + } +} + +/** Expose only the original non-executable argv for an expanded, freshly + * revalidated row. Missing or ambiguous private correlation never guesses. */ +export function resolveRevalidatedVaultResumeDetails(args: { + session: VaultResumeSession + sessionRecordStore: AgentSessionRecordStore +}): VaultResumeDetailsResult { + if (!isResumableTuiAgent(args.session.agent)) { + return { status: 'unavailable' } + } + const snapshotArgs = args.sessionRecordStore.resolveVaultSnapshotArguments({ + baseAgent: args.session.agent, + scannedProviderSessionId: args.session.sessionId, + scannedTranscriptPath: args.session.filePath, + scannedExecutionHostId: args.session.executionHostId + }) + return snapshotArgs && snapshotArgs.length > 0 + ? { status: 'ok', args: snapshotArgs } + : { status: 'unavailable' } +} + +/** Build the resume startup for a re-validated (host-discovered) session. */ +export function buildVaultResumeStartup(args: { + session: VaultResumeSession + /** The spawning host's platform, used only for local sessions; a non-local + * session uses its own discovered host platform. */ + hostPlatform: NodeJS.Platform + settings?: VaultResumeAssemblySettings +}): VaultResumeStartup { + const { session, hostPlatform, settings } = args + const commandOverride = settings?.agentCmdOverrides?.[session.agent as TuiAgent] ?? null + const isRemote = !!session.executionHostId && session.executionHostId !== LOCAL_EXECUTION_HOST_ID + // Remote-verbatim: a remote host stamped a ready-to-run resume command at + // discovery time; replay it as-is rather than re-deriving remote semantics. + if (isRemote && session.resumeCommand && !commandOverride?.trim()) { + return { command: session.resumeCommand } + } + const platform: NodeJS.Platform = + isRemote && session.executionHostPlatform ? session.executionHostPlatform : hostPlatform + const codexHome = resolveVaultResumeCodexHome(session.codexHome ?? null, platform) + // Why: the queued command is typed verbatim into a freshly spawned tab whose + // live shell is the configured Windows shell (default PowerShell). Hardcoding + // cmd quoting made PowerShell mis-parse the `""`-doubled wrapper (#6152), so + // resolve the actual shell to quote per-shell instead. + const queuedShell: AgentStartupShell | undefined = + platform === 'win32' + ? resolveWindowsShellStartupFamily(settings?.terminalWindowsShell) + : undefined + if (isResumableTuiAgent(session.agent)) { + const startupPlan = buildAgentResumeStartupPlan({ + agent: session.agent, + providerSession: { key: 'session_id', id: session.sessionId }, + cmdOverrides: { + ...settings?.agentCmdOverrides, + ...(commandOverride?.trim() ? { [session.agent]: commandOverride } : {}) + }, + platform, + shell: queuedShell, + agentArgs: resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings?.agentDefaultEnv) + }) + if (startupPlan) { + return { + command: buildAiVaultResumeShellCommand({ + resumeCommand: startupPlan.launchCommand, + cwd: session.cwd, + platform, + codexHome, + shell: queuedShell + }), + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig + } + } + } + + return { + command: buildAiVaultResumeCommand({ + agent: session.agent, + sessionId: session.sessionId, + // Why: OMP resumes by absolute transcript path, so local rebuilds must + // forward the host-derived path — an id-prefix lookup scoped to the default + // store would miss a custom OMP_CODING_AGENT_DIR / WSL-store session. + resumeFilePath: session.filePath, + cwd: session.cwd, + platform, + commandOverride, + codexHome, + // Why: non-resumable agents queue through this fallback too, so it must + // quote for the live Windows shell like the startup-plan branch above. + shell: queuedShell + }) + } +} + +function resolveVaultResumeCodexHome( + codexHome: string | null, + platform: NodeJS.Platform +): string | null { + // Why: WSL UNC Codex homes must be POSIX when invoking Linux commands. Keep + // original paths unchanged for non-Linux targets. + if (!codexHome || platform !== 'linux') { + return codexHome + } + return parseWslUncPath(codexHome)?.linuxPath ?? codexHome +} diff --git a/src/main/agent-launch/agent-launch-worktree-create-receipt.test.ts b/src/main/agent-launch/agent-launch-worktree-create-receipt.test.ts new file mode 100644 index 00000000000..0ca7e0c8c5f --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-create-receipt.test.ts @@ -0,0 +1,142 @@ +// Receipt-cannot-lie guard for the desktop-local create host-spawn (Ruling 1a): +// the CreatedWorktreeResult's `agentLaunchResult.status: 'launched'` is the sole +// signal the renderer reads to conclude "the host already spawned the primary +// agent terminal", and it must be inseparable from an actual registered PTY. In +// finishLocalWorktreeCreateAgentLaunch the receipt is recorded INSIDE the spawn +// closure, which the transaction runs before settle('registered') and only when +// createTerminal resolves. So a launched outcome implies a recorded receipt, and +// a spawn failure yields `failed` with no receipt — the signal cannot claim a +// primary the host did not spawn. This test drives the same transaction + spawn +// closure shape the runtime method uses. + +import { describe, expect, it, vi } from 'vitest' +import { AgentLaunchOperationStore } from './agent-launch-operation-store' +import { + runWorktreeAgentLaunchTransaction, + type WorktreeAgentLaunchTransactionDeps +} from './agent-launch-worktree-transaction' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract' + +const SNAPSHOT: AgentLaunchSnapshot = { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} + +const PLAN: AgentStartupPlan = { + agent: 'claude', + launchCommand: 'claude', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } +} + +const RECEIPT: AgentLaunchReceipt = { + requestedAgent: 'claude', + baseAgent: 'claude', + notices: [], + launchToken: 'tok-1', + catalogRevision: 3, + telemetry: { agentKind: 'claude-code', usedCustomAgent: false } +} + +function buildDeps( + operationStore: AgentLaunchOperationStore, + spawn: WorktreeAgentLaunchTransactionDeps['spawn'] +): WorktreeAgentLaunchTransactionDeps { + const boundary = { + pendingSnapshotFor: vi.fn(() => SNAPSHOT), + settleAgentLaunch: vi.fn() + } as unknown as WorktreeAgentLaunchTransactionDeps['boundary'] + return { + boundary, + operationStore, + persistPending: vi.fn(), + spawn, + clearPublicPending: vi.fn(), + persistFailure: vi.fn(), + mintFailureId: () => 'fail-1', + now: () => 1000 + } +} + +const PARAMS = { + operationId: 'op-1', + idempotencyKey: 'idem-1', + scope: 'wt-1', + payloadDigest: 'digest-1', + clientMutationId: null, + requestedAgent: 'claude' as const, + intent: 'interactive' as const, + execute: async () => ({ ok: true as const, plan: PLAN, receipt: RECEIPT }) +} + +describe('desktop-local create host-spawn receipt attribution', () => { + it('records the receipt exactly when the launch registers, so the launched signal is truthful', async () => { + const operationStore = new AgentLaunchOperationStore() + // Mirrors finishLocalWorktreeCreateAgentLaunch's spawn closure: createTerminal + // resolves, then the receipt is attributed to the registered terminal id. + const spawn = vi.fn(async (_plan: AgentStartupPlan, receipt: AgentLaunchReceipt) => { + operationStore.recordRegisteredReceipt('term-1', receipt) + return { terminalId: 'term-1' } + }) + const outcome = await runWorktreeAgentLaunchTransaction( + buildDeps(operationStore, spawn), + PARAMS + ) + expect(outcome.status).toBe('launched') + // The launched arm the renderer reads is backed by a recorded receipt. + expect(operationStore.registeredReceipt('term-1')).toEqual(RECEIPT) + }) + + it('reissues the local-git creation receipt on a settled-launched replay', async () => { + const operationStore = new AgentLaunchOperationStore() + // Mirrors createManagedWorktree's inline local-git spawn closure: it now + // records the receipt just like the other two spawn sites, so the settled + // ledger (which holds no token by design) can reissue the client-safe + // receipt from terminal attribution when a create is replayed after restart. + const spawn = vi.fn(async (_plan: AgentStartupPlan, receipt: AgentLaunchReceipt) => { + operationStore.recordRegisteredReceipt('local-git-term', receipt) + return { terminalId: 'local-git-term' } + }) + const outcome = await runWorktreeAgentLaunchTransaction( + buildDeps(operationStore, spawn), + PARAMS + ) + expect(outcome.status).toBe('launched') + const terminalId = outcome.status === 'launched' ? outcome.terminalId : null + expect(terminalId).toBe('local-git-term') + // resolveSettledWorktreeRetry reads exactly this to reissue `launched`; before + // the fix a local-git creation left no attribution and returned a stale reject. + expect(operationStore.registeredReceipt('local-git-term')).toEqual(RECEIPT) + }) + + it('never records a receipt when the spawn fails, so no launched signal can appear', async () => { + const operationStore = new AgentLaunchOperationStore() + // createTerminal throws before the receipt line runs — exactly as a real spawn + // failure would, so no attribution is left behind. + const spawn = vi.fn(async () => { + throw new Error('pty_spawn_failed') + }) + const outcome = await runWorktreeAgentLaunchTransaction( + buildDeps(operationStore, spawn), + PARAMS + ) + expect(outcome.status).toBe('failed') + expect(operationStore.registeredReceipt('term-1')).toBeNull() + }) +}) diff --git a/src/main/agent-launch/agent-launch-worktree-forget.test.ts b/src/main/agent-launch/agent-launch-worktree-forget.test.ts new file mode 100644 index 00000000000..baff3181096 --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-forget.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { + AgentLaunchOperationStore, + canonicalPayloadDigest, + type PendingAgentLaunchSnapshot +} from './agent-launch-operation-store' +import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation' +import { + runForgetUnknownAgentLaunch, + type ForgetUnknownAgentLaunchDeps +} from './agent-launch-worktree-forget' + +function snapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: true, + executionHostId: 'ssh:host' + } + } +} + +const OPERATION_ID = 'op-unknown-1' +const WORKTREE_ID = 'wt-1' +const CLIENT_MUTATION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const IDEMPOTENCY_KEY = 'idem-forget-1' + +function pending(): PendingAgentLaunchSnapshot { + return { + operationId: OPERATION_ID, + idempotencyKey: IDEMPOTENCY_KEY, + scope: WORKTREE_ID, + clientMutationId: CLIENT_MUTATION_ID, + payloadDigest: 'create-digest', + launchToken: 'token-unknown-1', + intent: 'interactive', + snapshot: snapshot() + } +} + +type ForgetTestDeps = ForgetUnknownAgentLaunchDeps & { + releaseReservation: ReturnType + clearPublicState: ReturnType +} + +function buildDeps( + store: AgentLaunchOperationStore, + overrides: Partial = {} +): ForgetTestDeps { + const releaseReservation = vi.fn<(launchToken: string) => void>() + const clearPublicState = vi.fn() + return { + operationStore: store, + idempotencyKeyFor: () => IDEMPOTENCY_KEY, + loadPendingSnapshot: () => store.getPending('token-unknown-1'), + loadFailureCode: () => 'launch_state_unknown', + releaseReservation, + clearPublicState, + now: () => 2000, + ...overrides + } as ForgetTestDeps +} + +describe('runForgetUnknownAgentLaunch', () => { + it('while unknown, Forget (not retry) releases the pending, token, and reservation', () => { + const store = new AgentLaunchOperationStore() + store.beginPending(pending()) + const deps = buildDeps(store) + + // Retry is blocked while unknown: the recovery gate refuses without mutation, + // so the trio is untouched by a retry. + expect(retryRecoveryGateForFailureCode('launch_state_unknown')).toEqual({ + kind: 'launch_state_unknown' + }) + expect(store.getPending('token-unknown-1')).not.toBeNull() + + const result = runForgetUnknownAgentLaunch(deps, { + scope: WORKTREE_ID, + expectedOperationId: OPERATION_ID, + clientMutationId: CLIENT_MUTATION_ID + }) + + expect(result).toEqual({ status: 'forgotten' }) + // Private attribution removed, reservation freed, public state cleared. + expect(store.getPending('token-unknown-1')).toBeNull() + expect(deps.releaseReservation).toHaveBeenCalledWith('token-unknown-1') + expect(deps.clearPublicState).toHaveBeenCalledTimes(1) + // Settled as `forgotten` for idempotency replay. + expect(store.findSettledByIdempotencyKey(WORKTREE_ID, IDEMPOTENCY_KEY)).toMatchObject({ + status: 'forgotten', + terminalId: null, + failureId: null + }) + }) + + it('replays forgotten on a double-submit without re-releasing', () => { + const store = new AgentLaunchOperationStore() + store.beginPending(pending()) + const deps = buildDeps(store) + const params = { + scope: WORKTREE_ID, + expectedOperationId: OPERATION_ID, + clientMutationId: CLIENT_MUTATION_ID + } + + expect(runForgetUnknownAgentLaunch(deps, params)).toEqual({ status: 'forgotten' }) + deps.releaseReservation.mockClear() + deps.clearPublicState.mockClear() + + // Second submit: the settled ledger replays `forgotten`, mutating nothing. + expect(runForgetUnknownAgentLaunch(deps, params)).toEqual({ status: 'forgotten' }) + expect(deps.releaseReservation).not.toHaveBeenCalled() + expect(deps.clearPublicState).not.toHaveBeenCalled() + }) + + it('rejects a stale operation id without mutation', () => { + const store = new AgentLaunchOperationStore() + store.beginPending(pending()) + const deps = buildDeps(store) + + const result = runForgetUnknownAgentLaunch(deps, { + scope: WORKTREE_ID, + expectedOperationId: 'op-stale', + clientMutationId: CLIENT_MUTATION_ID + }) + + expect(result).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + expect(store.getPending('token-unknown-1')).not.toBeNull() + expect(deps.releaseReservation).not.toHaveBeenCalled() + }) + + it('refuses to forget a launch that is not launch_state_unknown', () => { + const store = new AgentLaunchOperationStore() + store.beginPending(pending()) + const deps = buildDeps(store, { loadFailureCode: () => 'spawn_failed' }) + + const result = runForgetUnknownAgentLaunch(deps, { + scope: WORKTREE_ID, + expectedOperationId: OPERATION_ID, + clientMutationId: CLIENT_MUTATION_ID + }) + + expect(result).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + expect(store.getPending('token-unknown-1')).not.toBeNull() + expect(deps.releaseReservation).not.toHaveBeenCalled() + }) + + it('returns idempotency_conflict when the key was used with a different payload', () => { + const store = new AgentLaunchOperationStore() + store.recordSettled({ + operationId: 'op-other', + idempotencyKey: IDEMPOTENCY_KEY, + scope: WORKTREE_ID, + payloadDigest: canonicalPayloadDigest({ kind: 'forget', expectedOperationId: 'op-other' }), + status: 'forgotten', + terminalId: null, + failureId: null, + settledAt: 1 + }) + const deps = buildDeps(store) + + const result = runForgetUnknownAgentLaunch(deps, { + scope: WORKTREE_ID, + expectedOperationId: OPERATION_ID, + clientMutationId: CLIENT_MUTATION_ID + }) + + expect(result).toEqual({ status: 'rejected', requestError: { code: 'idempotency_conflict' } }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-worktree-forget.ts b/src/main/agent-launch/agent-launch-worktree-forget.ts new file mode 100644 index 00000000000..62ed567c0ea --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-forget.ts @@ -0,0 +1,115 @@ +// Pure orchestrator for `forgetUnknownAgentLaunch` (U4/U5). An authorized owner +// explicitly forgets a launch stranded in `launch_state_unknown` when Orca cannot +// reach the terminal host. Forgetting NEVER kills or spawns anything (the remote +// process may still be running); it only releases Orca's local bookkeeping: +// - settles the public attempt as `forgotten` in the idempotency ledger, +// - removes the private pending snapshot/token attribution, +// - frees the held admission reservation (capacity), +// - clears the public pending metadata and the unknown failure card. +// Guards, in order: idempotency replay first (a double-submit after a successful +// forget replays `forgotten` instead of hitting the now-empty pending), then the +// operation-id anti-race guard, then the "only from matching launch_state_unknown" +// gate. `expectedOperationId` is an anti-race guard, never authorization. +// Electron-free and injectable. + +import type { + AgentLaunchFailureCode, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import { + canonicalPayloadDigest, + type AgentLaunchOperationStore, + type PendingAgentLaunchSnapshot, + type SettledAgentLaunchOperation +} from './agent-launch-operation-store' + +export type ForgetUnknownAgentLaunchParams = { + /** Owner bucket for the op-store ledger/pending lookup: worktree id for an + * interactive launch, attempt id for a generic background attempt. */ + scope: string + expectedOperationId: string + clientMutationId: string +} + +// The client-safe forget result lives in shared so renderer, preload, and this +// host orchestrator type-check against one definition. +export type { ForgetUnknownAgentLaunchResult } from '../../shared/agent-launch-worktree-recovery' +import type { ForgetUnknownAgentLaunchResult } from '../../shared/agent-launch-worktree-recovery' + +export type ForgetUnknownAgentLaunchDeps = { + operationStore: AgentLaunchOperationStore + idempotencyKeyFor: (clientMutationId: string) => string + /** The private pending snapshot for this scope (source of the launch token and + * the authoritative operation id), or null once nothing is pending. */ + loadPendingSnapshot: () => PendingAgentLaunchSnapshot | null + /** The scope's current durable failure code; forget is allowed only when it is + * `launch_state_unknown`. */ + loadFailureCode: () => AgentLaunchFailureCode | undefined + /** Free the held admission reservation for the launch token (capacity). */ + releaseReservation: (launchToken: string) => void + /** Clear the public pending metadata and the unknown failure card. */ + clearPublicState: () => void + now?: () => number +} + +const FORGET_KIND = 'forget' as const + +function rejected(code: AgentLaunchRequestError['code']): ForgetUnknownAgentLaunchResult { + return { status: 'rejected', requestError: { code } } +} + +function resolveSettled(settled: SettledAgentLaunchOperation): ForgetUnknownAgentLaunchResult { + // Only a forget settles `forgotten`; any other settled status under this key + // means the mutation id was reused for a different operation. + return settled.status === 'forgotten' ? { status: 'forgotten' } : rejected('idempotency_conflict') +} + +export function runForgetUnknownAgentLaunch( + deps: ForgetUnknownAgentLaunchDeps, + params: ForgetUnknownAgentLaunchParams +): ForgetUnknownAgentLaunchResult { + const nowFn = deps.now ?? Date.now + const idempotencyKey = deps.idempotencyKeyFor(params.clientMutationId) + const payloadDigest = canonicalPayloadDigest({ + kind: FORGET_KIND, + expectedOperationId: params.expectedOperationId + }) + + // 1. Idempotency first: a settled ledger entry replays without re-mutating. + const settled = deps.operationStore.findSettledByIdempotencyKey(params.scope, idempotencyKey) + if (settled) { + return settled.payloadDigest === payloadDigest + ? resolveSettled(settled) + : rejected('idempotency_conflict') + } + + // 2. Operation-id anti-race guard: the private pending must still be present and + // name the operation the client believes it is forgetting. + const pending = deps.loadPendingSnapshot() + if (!pending || pending.operationId !== params.expectedOperationId) { + return rejected('stale_agent_launch_failure') + } + + // 3. Only a matching launch_state_unknown is forgettable; any other state means + // reconciliation already resolved it, so there is nothing stranded to forget. + if (deps.loadFailureCode() !== 'launch_state_unknown') { + return rejected('stale_agent_launch_failure') + } + + // Settle `forgotten`, drop the private attribution, and free the reservation. + // No kill/spawn: a later provider terminal is treated as unattributed. + deps.operationStore.recordSettled({ + operationId: pending.operationId, + idempotencyKey, + scope: params.scope, + payloadDigest, + status: 'forgotten', + terminalId: null, + failureId: null, + settledAt: nowFn() + }) + deps.operationStore.clearPending(pending.launchToken) + deps.releaseReservation(pending.launchToken) + deps.clearPublicState() + return { status: 'forgotten' } +} diff --git a/src/main/agent-launch/agent-launch-worktree-reconcile-writer.test.ts b/src/main/agent-launch/agent-launch-worktree-reconcile-writer.test.ts new file mode 100644 index 00000000000..41ef99f843f --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-reconcile-writer.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import { + AgentLaunchOperationStore, + type PendingAgentLaunchSnapshot +} from './agent-launch-operation-store' +import { + reconcileAllPendingAgentLaunches, + reconcileOnePendingAgentLaunch, + type ReconcileAgentLaunchDeps, + type ReconcileScopePersistence, + type ResolvedLaunchLiveness +} from './agent-launch-worktree-reconcile-writer' + +function snapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function pending(overrides: Partial = {}): PendingAgentLaunchSnapshot { + return { + operationId: 'op-1', + idempotencyKey: 'idem-1', + scope: 'wt-1', + clientMutationId: 'cmid-1', + payloadDigest: 'digest-1', + launchToken: 'token-1', + intent: 'interactive', + snapshot: snapshot(), + ...overrides + } +} + +function buildDeps( + store: AgentLaunchOperationStore, + liveness: ResolvedLaunchLiveness, + persistence: ReconcileScopePersistence, + settleBoundary = vi.fn() +): ReconcileAgentLaunchDeps { + let failureCounter = 0 + return { + operationStore: store, + resolveLiveness: () => liveness, + persistenceFor: () => persistence, + settleBoundary, + mintFailureId: () => `failure-${(failureCounter += 1)}`, + now: () => 1000 + } +} + +function persistenceSpy(): ReconcileScopePersistence & { + launched: ReturnType + failed: ReturnType + unknown: ReturnType +} { + const launched = vi.fn() + const failed = vi.fn<(failure: PersistedAgentLaunchFailure) => void>() + const unknown = vi.fn<(failure: PersistedAgentLaunchFailure) => void>() + return { + settleLaunched: launched, + settleFailed: failed, + markUnknown: unknown, + launched, + failed, + unknown + } +} + +describe('reconcileOnePendingAgentLaunch', () => { + it('live+attributed settles launched, clears pending, and registers the boundary', () => { + const store = new AgentLaunchOperationStore() + const entry = pending() + store.beginPending(entry) + const persistence = persistenceSpy() + const settleBoundary = vi.fn() + const deps = buildDeps( + store, + { kind: 'live', attributed: true, terminalId: 'term-9' }, + persistence, + settleBoundary + ) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'launched' }) + expect(settleBoundary).toHaveBeenCalledWith('token-1', 'registered') + expect(store.getPending('token-1')).toBeNull() + expect(persistence.launched).toHaveBeenCalledTimes(1) + const settled = store.findSettledByIdempotencyKey('wt-1', 'idem-1') + expect(settled).toMatchObject({ status: 'launched', terminalId: 'term-9', failureId: null }) + }) + + it('live+unattributed records invalid_launch_snapshot without tearing the terminal down', () => { + const store = new AgentLaunchOperationStore() + const entry = pending() + store.beginPending(entry) + const persistence = persistenceSpy() + const settleBoundary = vi.fn() + const deps = buildDeps( + store, + { kind: 'live', attributed: false, terminalId: 'term-hijack' }, + persistence, + settleBoundary + ) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'invalid_launch_snapshot' }) + expect(settleBoundary).toHaveBeenCalledWith('token-1', 'failed') + expect(store.getPending('token-1')).toBeNull() + const failure = persistence.failed.mock.calls[0][0] + expect(failure).toMatchObject({ code: 'invalid_launch_snapshot', intent: 'interactive' }) + expect(store.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({ + status: 'failed', + terminalId: 'term-hijack' + }) + }) + + it('absent settles spawn_failed with Retry available', () => { + const store = new AgentLaunchOperationStore() + const entry = pending() + store.beginPending(entry) + const persistence = persistenceSpy() + const deps = buildDeps(store, { kind: 'absent' }, persistence) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'spawn_failed' }) + expect(store.getPending('token-1')).toBeNull() + const failure = persistence.failed.mock.calls[0][0] + expect(failure).toMatchObject({ code: 'spawn_failed', intent: 'interactive' }) + expect(store.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({ + status: 'failed', + terminalId: null + }) + }) + + it('unknown writes the durable failure but keeps pending, snapshot, and reservation', () => { + const store = new AgentLaunchOperationStore() + const entry = pending() + store.beginPending(entry) + const persistence = persistenceSpy() + const settleBoundary = vi.fn() + const deps = buildDeps(store, { kind: 'unknown' }, persistence, settleBoundary) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'launch_state_unknown' }) + // Coexistence: the operation is NOT settled and nothing is released. + expect(settleBoundary).not.toHaveBeenCalled() + expect(store.getPending('token-1')).not.toBeNull() + expect(store.findSettledByIdempotencyKey('wt-1', 'idem-1')).toBeNull() + const failure = persistence.unknown.mock.calls[0][0] + expect(failure).toMatchObject({ code: 'launch_state_unknown', intent: 'interactive' }) + expect(failure.failureId).toBeTruthy() + }) + + it('skips a snapshot a concurrent settle already cleared', () => { + const store = new AgentLaunchOperationStore() + const entry = pending() + // Not begun in the store: models a token already settled/forgotten elsewhere. + const persistence = persistenceSpy() + const settleBoundary = vi.fn() + const deps = buildDeps(store, { kind: 'absent' }, persistence, settleBoundary) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toBeNull() + expect(settleBoundary).not.toHaveBeenCalled() + expect(persistence.failed).not.toHaveBeenCalled() + }) +}) + +describe('reconcileAllPendingAgentLaunches', () => { + it('reconciles only the filtered scope', () => { + const store = new AgentLaunchOperationStore() + store.beginPending(pending()) + store.beginPending( + pending({ + scope: 'wt-2', + launchToken: 'token-2', + operationId: 'op-2', + idempotencyKey: 'idem-2' + }) + ) + const persistence = persistenceSpy() + const deps: ReconcileAgentLaunchDeps = { + ...buildDeps(store, { kind: 'absent' }, persistence), + persistenceFor: () => persistence + } + + reconcileAllPendingAgentLaunches(deps, (entry) => entry.scope === 'wt-2') + + expect(store.getPending('token-2')).toBeNull() + expect(store.getPending('token-1')).not.toBeNull() + expect(persistence.failed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/agent-launch/agent-launch-worktree-reconcile-writer.ts b/src/main/agent-launch/agent-launch-worktree-reconcile-writer.ts new file mode 100644 index 00000000000..1ed8fd1153c --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-reconcile-writer.ts @@ -0,0 +1,156 @@ +// The event-driven WRITER half of U4/U5 reconciliation. The pure decision lives +// in agent-launch-reconciliation.ts; this module takes a resolved liveness for a +// pending launch snapshot and persists the mapped outcome through injected +// callbacks, enforcing the plan's coexistence rule for the unknown state: +// launched → settle the boundary registered, record `launched`, +// clear pending (public + private), clear the failure. +// invalid_launch_snapshot → record a durable failure, settle failed, clear +// pending; NEVER tears down the live-but-unattributed +// terminal (the retry gate blocks Retry while live). +// spawn_failed → record a durable failure, settle failed, clear +// pending; Retry becomes available. +// launch_state_unknown → write the durable failure ONLY. The public pending, +// the private snapshot/token, and the held admission +// reservation ALL survive until a live/absent proof or +// an explicit Forget releases them (never settled here). +// Electron-free and injectable; the runtime supplies liveness + persistence. + +import type { + AgentLaunchFailure, + AgentLaunchFailureCode, + AgentLaunchIntentKind, + PersistedAgentLaunchFailure +} from '../../shared/agent-launch-contract' +import type { + AgentLaunchOperationStore, + PendingAgentLaunchSnapshot +} from './agent-launch-operation-store' +import { + reconcileAgentLaunchLiveness, + type AgentLaunchReconcileOutcome, + type ProviderLiveness +} from './agent-launch-reconciliation' + +/** Liveness the runtime resolves for one pending launch token against its own + * live terminal view. `attributed` is whether a token-matched live terminal + * still belongs to the launch's scope; `terminalId` names it for the ledger. */ +export type ResolvedLaunchLiveness = + | { kind: 'live'; attributed: boolean; terminalId: string } + | { kind: 'absent' } + | { kind: 'unknown' } + +/** Per-scope durable writes the reconciler drives. `settleLaunched`/`settleFailed` + * clear the public pending; `markUnknown` MUST retain it (coexistence rule) and + * should keep any existing launch_state_unknown failureId stable across idempotent + * re-runs so the client's expectedFailureId guard does not churn. */ +export type ReconcileScopePersistence = { + settleLaunched: () => void + settleFailed: (failure: PersistedAgentLaunchFailure) => void + markUnknown: (failure: PersistedAgentLaunchFailure) => void +} + +export type ReconcileAgentLaunchDeps = { + operationStore: AgentLaunchOperationStore + resolveLiveness: (pending: PendingAgentLaunchSnapshot) => ResolvedLaunchLiveness + // Routes on the pending's INTENT (not just its scope string) so background, + // automation, orchestration, and worktree launches land in their own owner + // record even when two owners happen to share a scope id namespace. + persistenceFor: (pending: PendingAgentLaunchSnapshot) => ReconcileScopePersistence + settleBoundary: (launchToken: string, settlement: 'registered' | 'failed') => void + mintFailureId: () => string + now?: () => number +} + +function toProviderLiveness(liveness: ResolvedLaunchLiveness): ProviderLiveness { + return liveness.kind === 'live' + ? { kind: 'live', attributed: liveness.attributed } + : { kind: liveness.kind } +} + +function persistedFailure( + code: AgentLaunchFailureCode, + pending: PendingAgentLaunchSnapshot, + deps: ReconcileAgentLaunchDeps, + intent: AgentLaunchIntentKind, + occurredAt: number +): PersistedAgentLaunchFailure { + const failure: AgentLaunchFailure = { + code, + requestedAgent: pending.snapshot.requestedAgent, + baseAgent: pending.snapshot.baseAgent + } + return { ...failure, version: 1, failureId: deps.mintFailureId(), intent, occurredAt } +} + +/** Reconcile ONE pending launch snapshot against resolved liveness and persist + * the mapped outcome. Idempotent: a snapshot a concurrent transaction/forget + * already settled is skipped. Returns the applied outcome, or null if skipped. */ +export function reconcileOnePendingAgentLaunch( + deps: ReconcileAgentLaunchDeps, + pending: PendingAgentLaunchSnapshot +): AgentLaunchReconcileOutcome | null { + const nowFn = deps.now ?? Date.now + // Re-read: a concurrent transaction/forget may have settled this token first. + if (!deps.operationStore.getPending(pending.launchToken)) { + return null + } + const liveness = deps.resolveLiveness(pending) + const outcome = reconcileAgentLaunchLiveness(toProviderLiveness(liveness)) + const persistence = deps.persistenceFor(pending) + const liveTerminalId = liveness.kind === 'live' ? liveness.terminalId : null + + if (outcome.kind === 'launched') { + deps.settleBoundary(pending.launchToken, 'registered') + deps.operationStore.recordSettled({ + operationId: pending.operationId, + idempotencyKey: pending.idempotencyKey, + scope: pending.scope, + payloadDigest: pending.payloadDigest, + status: 'launched', + terminalId: liveTerminalId, + failureId: null, + settledAt: nowFn() + }) + deps.operationStore.clearPending(pending.launchToken) + persistence.settleLaunched() + return outcome + } + + if (outcome.kind === 'invalid_launch_snapshot' || outcome.kind === 'spawn_failed') { + const failure = persistedFailure(outcome.kind, pending, deps, pending.intent, nowFn()) + deps.settleBoundary(pending.launchToken, 'failed') + deps.operationStore.recordSettled({ + operationId: pending.operationId, + idempotencyKey: pending.idempotencyKey, + scope: pending.scope, + payloadDigest: pending.payloadDigest, + status: 'failed', + terminalId: liveTerminalId, + failureId: failure.failureId, + settledAt: nowFn() + }) + deps.operationStore.clearPending(pending.launchToken) + persistence.settleFailed(failure) + return outcome + } + + // launch_state_unknown — coexistence rule: settle nothing, clear nothing, + // release nothing. Only the durable failure card is (re)written. + const failure = persistedFailure('launch_state_unknown', pending, deps, pending.intent, nowFn()) + persistence.markUnknown(failure) + return outcome +} + +/** Run reconciliation across every pending snapshot (optionally filtered to a + * scope/provider). Snapshot the list first so per-entry clears do not disturb + * iteration. */ +export function reconcileAllPendingAgentLaunches( + deps: ReconcileAgentLaunchDeps, + filter?: (pending: PendingAgentLaunchSnapshot) => boolean +): void { + for (const pending of deps.operationStore.pendingSnapshots()) { + if (!filter || filter(pending)) { + reconcileOnePendingAgentLaunch(deps, pending) + } + } +} diff --git a/src/main/agent-launch/agent-launch-worktree-resolution.test.ts b/src/main/agent-launch/agent-launch-worktree-resolution.test.ts new file mode 100644 index 00000000000..a35c1852ff9 --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-resolution.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from 'vitest' +import { + prepareWorktreeAgentLaunch, + executeWorktreeAgentLaunch, + type WorktreeAgentLaunchContext, + type WorktreeAgentLaunchDeps +} from './agent-launch-worktree-resolution' +import { AgentLaunchBoundary } from './agent-launch-boundary' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator, + type AdmissionPrincipal +} from './agent-launch-admission-store' +import type { GlobalSettings } from '../../shared/types' +import type { + ResolveAgentLaunchRequest, + ResolvedAgentLaunch, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch' + +const LOCAL: AdmissionPrincipal = { kind: 'local' } + +function makeSnapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['/opt/claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function makeLaunch( + fingerprint: string, + stableInputDigest: string, + worktreePath: string | null +): ResolvedAgentLaunch { + const snapshot = makeSnapshot() + return { + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + argv: snapshot.argv, + agentEnv: snapshot.agentEnv, + variables: { values: { repoPath: '/repo', worktreePath }, referenced: ['worktreePath'] }, + snapshot, + policy: { + intent: 'interactive', + mode: 'built-in', + client: 'desktop', + isRemote: false, + platform: 'linux', + promptInjectionMode: 'stdin-after-start', + expectedProcess: 'claude', + env: 'none' + }, + notices: [], + telemetry: { agentKind: 'claude-code', usedCustomAgent: false }, + admissionGuard: { fingerprint, stableInputDigest, basis: 'default' } + } +} + +function makeSetup(resolve: (request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome): { + deps: WorktreeAgentLaunchDeps + store: AgentLaunchAdmissionStore +} { + const store = new AgentLaunchAdmissionStore() + const boundary = new AgentLaunchBoundary({ + admissionStore: store, + coordinator: new LaunchAdmissionCoordinator(), + now: () => 1000 + }) + const deps: WorktreeAgentLaunchDeps = { + boundary, + getSettings: () => ({}) as GlobalSettings, + getCatalogRevision: () => 5, + detectStockBaseAgents: async () => null, + resolveTargetHomePath: async () => '/home/dev', + resolve: (request) => resolve(request) + } + return { deps, store } +} + +const CONTEXT: WorktreeAgentLaunchContext = { + request: { selection: { kind: 'default' }, allowEmptyPromptLaunch: true }, + intent: { kind: 'interactive', client: 'desktop' }, + descriptor: { kind: 'local', platform: 'linux', shell: 'posix' }, + scope: 'wt-op', + principal: LOCAL +} + +describe('two-stage worktree agent-launch resolution', () => { + it('pins the config digest pre-git and admits it post-git across a changed path', async () => { + const resolve = vi + .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() + .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') }) + .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) + .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) + const { deps, store } = makeSetup(resolve) + + const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { + repoPath: '/repo', + worktreePath: '/wt-provisional' + }) + expect(prepared.ok).toBe(true) + if (!prepared.ok) { + return + } + expect(prepared.stableInputDigest).toBe('sd-1') + expect(prepared.requestedAgent).toBe('claude') + // The hold counts before commit; nothing is admitted yet. + expect(store.pendingForPrincipal(LOCAL)).toBe(1) + expect(store.pendingCount()).toBe(0) + + const executed = await executeWorktreeAgentLaunch( + deps, + CONTEXT, + { repoPath: '/repo', worktreePath: '/wt-real' }, + { + reservationId: prepared.reservationId, + expectedStableInputDigest: prepared.stableInputDigest + } + ) + expect(executed.ok).toBe(true) + if (!executed.ok) { + return + } + // The reservation converted into exactly one admitted token; no double-count. + expect(store.pendingForPrincipal(LOCAL)).toBe(1) + expect(store.get(executed.receipt.launchToken)?.snapshot.requestedAgent).toBe('claude') + // Final resolution ran against the authoritative worktree path. + expect(resolve.mock.calls[1]![0].variables.worktreePath).toBe('/wt-real') + }) + + it('releases the reservation and reports a config change when the digest moved', async () => { + const resolve = vi + .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() + .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') }) + .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-2', '/wt-real') }) + const { deps, store } = makeSetup(resolve) + + const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { + repoPath: '/repo', + worktreePath: '/wt-provisional' + }) + expect(prepared.ok).toBe(true) + if (!prepared.ok) { + return + } + + const executed = await executeWorktreeAgentLaunch( + deps, + CONTEXT, + { repoPath: '/repo', worktreePath: '/wt-real' }, + { + reservationId: prepared.reservationId, + expectedStableInputDigest: prepared.stableInputDigest + } + ) + expect(executed.ok).toBe(false) + if (executed.ok) { + return + } + expect('failure' in executed && executed.failure.code).toBe('agent_configuration_changed') + // A rejected two-stage launch never permanently burns capacity. + expect(store.pendingForPrincipal(LOCAL)).toBe(0) + expect(store.pendingCount()).toBe(0) + }) + + it('takes no reservation when pre-git resolution fails', async () => { + const resolve = vi + .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() + .mockReturnValueOnce({ ok: false, failure: { code: 'custom_agent_disabled' } }) + const { deps, store } = makeSetup(resolve) + + const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { + repoPath: '/repo', + worktreePath: '/wt-provisional' + }) + expect(prepared.ok).toBe(false) + expect(store.pendingForPrincipal(LOCAL)).toBe(0) + }) +}) diff --git a/src/main/agent-launch/agent-launch-worktree-resolution.ts b/src/main/agent-launch/agent-launch-worktree-resolution.ts new file mode 100644 index 00000000000..cb5d79befcd --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-resolution.ts @@ -0,0 +1,181 @@ +// Two-stage host resolution for a worktree-creation `agentLaunch` request (U4). +// Stage 1 (pre-git) pins the concrete requested identity + config-only digest and +// takes one of the 256 admission reservations BEFORE any git side effect, so a +// launch_capacity_exceeded (or a deterministic identity/enabled/template failure) +// aborts creation without leaving an orphan worktree. Stage 2 (post-git) re-reads +// one atomic settings/catalog view for BOTH the digest recheck and final +// resolution against the authoritative worktree path, converting the held +// reservation into an admitted token/snapshot/plan or releasing it. The client's +// command/env/launchConfig/launchAgent are IGNORED — only the host-resolved plan +// spawns. Electron-free and injection-based so it is unit-testable. + +import type { GlobalSettings } from '../../shared/types' +import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request' +import type { LaunchIntent, ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract' +import { + deriveAgentLaunchHostState, + type AgentLaunchHostDescriptor, + type AgentLaunchHostStateDeps +} from './agent-launch-host-state' +import { buildHostStateResolve } from './agent-launch-spawn' +import { STARTUP_COMMAND_TEXT_MAX_CHARS } from '../providers/windows-shell-args' +import type { resolveAgentLaunch } from './resolve-agent-launch' +import type { + AgentLaunchBoundary, + ExecuteAgentLaunchResult, + PrepareReservedAgentLaunchResult +} from './agent-launch-boundary' +import type { AdmissionPrincipal } from './agent-launch-admission-store' +import type { + AgentLaunchFailure, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' + +/** A pre-create (stage 1) launch rejection. Thrown so the worktree-create RPC + * aborts BEFORE any git mutation — capacity and deterministic identity/enabled/ + * template failures create no worktree. The structured failure/requestError is + * carried for the caller surface to render; it is never a created-worktree + * result. */ +export class WorktreeAgentLaunchPreCreateError extends Error { + readonly failure?: AgentLaunchFailure + readonly requestError?: AgentLaunchRequestError + constructor(rejection: { failure?: AgentLaunchFailure; requestError?: AgentLaunchRequestError }) { + super( + rejection.failure + ? `agent_launch_precreate_failed:${rejection.failure.code}` + : `agent_launch_precreate_rejected:${rejection.requestError?.code ?? 'unknown'}` + ) + this.name = 'WorktreeAgentLaunchPreCreateError' + if (rejection.failure) { + this.failure = rejection.failure + } + if (rejection.requestError) { + this.requestError = rejection.requestError + } + } +} + +export type WorktreeAgentLaunchDeps = { + boundary: AgentLaunchBoundary + getSettings: () => GlobalSettings + getCatalogRevision: () => number + detectStockBaseAgents: AgentLaunchHostStateDeps['detectStockBaseAgents'] + resolveTargetHomePath: AgentLaunchHostStateDeps['resolveTargetHomePath'] + resolveTransportConfidentiality?: AgentLaunchHostStateDeps['resolveTransportConfidentiality'] + /** Best-effort workspace trust for the resolved base agent, run as the + * boundary's pre-admission preflight OUTSIDE the coordinator. A throw maps to + * trust_preflight_failed with no admission record and the reservation freed. */ + markWorkspaceTrusted?: (launch: ResolvedAgentLaunch) => Promise | void + /** Provider env preparation, OUTSIDE the coordinator; same failure mapping. */ + prepareEnv?: (launch: ResolvedAgentLaunch) => Promise | void + /** Injectable total resolver for tests; defaults to the real one. */ + resolve?: typeof resolveAgentLaunch +} + +/** The immutable per-creation context shared by both stages. `provisionalPaths` + * seed the pre-git resolve (variable NAMES validate, values are provisional); + * `authoritativePaths` are the real repo/worktree paths after git created the + * workspace. */ +export type WorktreeAgentLaunchContext = { + request: AgentLaunchSpawnRequest + intent: LaunchIntent + descriptor: AgentLaunchHostDescriptor + scope: string + principal: AdmissionPrincipal +} + +function toSpawnDeps(deps: WorktreeAgentLaunchDeps): { + getSettings: () => GlobalSettings + getCatalogRevision: () => number + boundary: AgentLaunchBoundary + resolve?: typeof resolveAgentLaunch +} { + return { + getSettings: deps.getSettings, + getCatalogRevision: deps.getCatalogRevision, + boundary: deps.boundary, + ...(deps.resolve ? { resolve: deps.resolve } : {}) + } +} + +/** Stage 1: pin identity + config-only digest and reserve capacity, all before + * git mutation. On failure NO reservation is held and the caller must not + * create the worktree. */ +export async function prepareWorktreeAgentLaunch( + deps: WorktreeAgentLaunchDeps, + context: WorktreeAgentLaunchContext, + provisionalPaths: { repoPath: string | null; worktreePath: string | null } +): Promise { + const hostState = await deriveAgentLaunchHostState( + { + getSettings: deps.getSettings, + getCatalogRevision: deps.getCatalogRevision, + detectStockBaseAgents: deps.detectStockBaseAgents, + resolveTargetHomePath: deps.resolveTargetHomePath, + ...(deps.resolveTransportConfidentiality + ? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality } + : {}) + }, + context.descriptor, + provisionalPaths + ) + const resolve = buildHostStateResolve(toSpawnDeps(deps), { + request: context.request, + intent: context.intent, + target: hostState.target, + variables: hostState.variables, + scope: context.scope, + principal: context.principal + }) + return deps.boundary.prepareReservedAgentLaunch({ principal: context.principal, resolve }) +} + +/** Stage 2: with the authoritative worktree path and the pinned reservation, + * re-resolve, recheck the config-only digest, and convert the reservation into + * a startup plan + receipt (or release it on any failure). Creates no PTY: the + * caller persists the pending record, then spawns and settles. */ +export async function executeWorktreeAgentLaunch( + deps: WorktreeAgentLaunchDeps, + context: WorktreeAgentLaunchContext, + authoritativePaths: { repoPath: string | null; worktreePath: string | null }, + reservation: { reservationId: string; expectedStableInputDigest: string } +): Promise { + const hostState = await deriveAgentLaunchHostState( + { + getSettings: deps.getSettings, + getCatalogRevision: deps.getCatalogRevision, + detectStockBaseAgents: deps.detectStockBaseAgents, + resolveTargetHomePath: deps.resolveTargetHomePath, + ...(deps.resolveTransportConfidentiality + ? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality } + : {}) + }, + context.descriptor, + authoritativePaths + ) + const resolve = buildHostStateResolve(toSpawnDeps(deps), { + request: context.request, + intent: context.intent, + target: hostState.target, + variables: hostState.variables, + scope: context.scope, + principal: context.principal + }) + return deps.boundary.executeReservedAgentLaunch({ + scope: context.scope, + principal: context.principal, + resolve, + prompt: context.request.prompt ?? '', + ...(context.request.allowEmptyPromptLaunch !== undefined + ? { allowEmptyPromptLaunch: context.request.allowEmptyPromptLaunch } + : {}), + ...(context.request.promptDelivery !== undefined + ? { promptDelivery: context.request.promptDelivery } + : {}), + maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS, + ...(deps.markWorkspaceTrusted ? { preflight: deps.markWorkspaceTrusted } : {}), + ...(deps.prepareEnv ? { prepareEnv: deps.prepareEnv } : {}), + reservationId: reservation.reservationId, + expectedStableInputDigest: reservation.expectedStableInputDigest + }) +} diff --git a/src/main/agent-launch/agent-launch-worktree-retry-host.ts b/src/main/agent-launch/agent-launch-worktree-retry-host.ts new file mode 100644 index 00000000000..bca4b712615 --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-retry-host.ts @@ -0,0 +1,32 @@ +// Ephemeral host-wide in-flight join registry for `worktree.retryAgentLaunch`. +// A retry launch is registered here by idempotency key while it runs so a +// concurrent duplicate (double-click, client reconnect) joins the same promise +// instead of starting a second launch; the entry clears when the promise +// settles. This is in-memory only — cross-restart idempotency is the durable +// settled ledger's job, not this registry's. + +import type { + WorktreeRetryAgentLaunchResult, + WorktreeRetryInFlight +} from './agent-launch-worktree-retry' + +const inFlightByKey = new Map() + +export function findWorktreeRetryInFlight(idempotencyKey: string): WorktreeRetryInFlight | null { + return inFlightByKey.get(idempotencyKey) ?? null +} + +export function registerWorktreeRetryInFlight( + idempotencyKey: string, + payloadDigest: string, + promise: Promise +): void { + inFlightByKey.set(idempotencyKey, { payloadDigest, promise }) + const clear = (): void => { + // Only clear our own entry — a newer duplicate may have replaced it. + if (inFlightByKey.get(idempotencyKey)?.promise === promise) { + inFlightByKey.delete(idempotencyKey) + } + } + void promise.then(clear, clear) +} diff --git a/src/main/agent-launch/agent-launch-worktree-retry.test.ts b/src/main/agent-launch/agent-launch-worktree-retry.test.ts new file mode 100644 index 00000000000..0bdc0eeb30e --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-retry.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from 'vitest' +import { + runWorktreeRetryAgentLaunch, + type RetryRecoveryGate, + type WorktreeRetryAgentLaunchDeps, + type WorktreeRetryAgentLaunchParams, + type WorktreeRetryAgentLaunchResult, + type WorktreeRetryInFlight +} from './agent-launch-worktree-retry' +import { AgentLaunchOperationStore, canonicalPayloadDigest } from './agent-launch-operation-store' +import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request' +import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' + +const WORKTREE = 'repo::/wt' +const FAILURE_ID = 'f1' +const IDEMPOTENCY_KEY = 'key-abc' + +function durableFailure( + overrides: Partial = {} +): PersistedAgentLaunchFailure { + return { + code: 'spawn_failed', + requestedAgent: 'claude', + version: 1, + failureId: FAILURE_ID, + intent: 'interactive', + occurredAt: 1, + ...overrides + } +} + +type Harness = { + deps: WorktreeRetryAgentLaunchDeps + runLaunch: ReturnType + registerInFlight: ReturnType + resolveSettled: ReturnType + store: AgentLaunchOperationStore + requests: AgentLaunchSpawnRequest[] +} + +function harness(overrides: Partial = {}): Harness { + const store = new AgentLaunchOperationStore() + const requests: AgentLaunchSpawnRequest[] = [] + const launched: WorktreeRetryAgentLaunchResult = { + status: 'launched', + receipt: { + requestedAgent: 'claude', + baseAgent: 'claude', + notices: [], + launchToken: 'tok', + catalogRevision: 1, + telemetry: { agentKind: 'claude-code', usedCustomAgent: false } + } + } + const runLaunch = vi.fn(async (input: { request: AgentLaunchSpawnRequest }) => { + requests.push(input.request) + return launched + }) + const registerInFlight = vi.fn() + const resolveSettled = vi.fn( + (): WorktreeRetryAgentLaunchResult => ({ status: 'launched', receipt: launched.receipt }) + ) + const deps: WorktreeRetryAgentLaunchDeps = { + operationStore: store, + idempotencyKeyFor: () => IDEMPOTENCY_KEY, + findInFlight: () => null, + registerInFlight, + resolveSettled, + loadDurableFailure: () => durableFailure(), + resolveRecoveryGate: (): RetryRecoveryGate => ({ kind: 'retryable' }), + runLaunch, + ...overrides + } + return { deps, runLaunch, registerInFlight, resolveSettled, store, requests } +} + +const RETRY_SAME: WorktreeRetryAgentLaunchParams = { + scope: WORKTREE, + expectedFailureId: FAILURE_ID, + clientMutationId: '00000000-0000-4000-8000-000000000000', + action: { kind: 'retry-same' } +} + +describe('runWorktreeRetryAgentLaunch idempotency', () => { + it('replays the settled ledger result when key + payload match', async () => { + const h = harness() + h.store.recordSettled({ + operationId: 'op1', + idempotencyKey: IDEMPOTENCY_KEY, + scope: WORKTREE, + payloadDigest: canonicalPayloadDigest({ kind: 'retry-same' }), + status: 'launched', + terminalId: 't1', + failureId: null, + settledAt: 1 + }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result.status).toBe('launched') + expect(h.resolveSettled).toHaveBeenCalledOnce() + expect(h.runLaunch).not.toHaveBeenCalled() + }) + + it('returns idempotency_conflict when the settled key is reused with a different payload', async () => { + const h = harness() + h.store.recordSettled({ + operationId: 'op1', + idempotencyKey: IDEMPOTENCY_KEY, + scope: WORKTREE, + payloadDigest: canonicalPayloadDigest({ kind: 'change-agent', agent: 'codex' }), + status: 'launched', + terminalId: 't1', + failureId: null, + settledAt: 1 + }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result).toEqual({ status: 'rejected', requestError: { code: 'idempotency_conflict' } }) + expect(h.runLaunch).not.toHaveBeenCalled() + }) + + it('joins the in-flight promise when key + payload match', async () => { + const inflightResult: WorktreeRetryAgentLaunchResult = { + status: 'launched', + receipt: { + requestedAgent: 'claude', + baseAgent: 'claude', + notices: [], + launchToken: 'inflight', + catalogRevision: 1, + telemetry: { agentKind: 'claude-code', usedCustomAgent: false } + } + } + const inFlight: WorktreeRetryInFlight = { + payloadDigest: canonicalPayloadDigest({ kind: 'retry-same' }), + promise: Promise.resolve(inflightResult) + } + const h = harness({ findInFlight: () => inFlight }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result).toBe(inflightResult) + expect(h.runLaunch).not.toHaveBeenCalled() + }) + + it('returns idempotency_conflict when an in-flight key has a different payload', async () => { + const inFlight: WorktreeRetryInFlight = { + payloadDigest: canonicalPayloadDigest({ kind: 'change-agent', agent: 'codex' }), + promise: Promise.resolve({ status: 'launched' } as WorktreeRetryAgentLaunchResult) + } + const h = harness({ findInFlight: () => inFlight }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result).toEqual({ status: 'rejected', requestError: { code: 'idempotency_conflict' } }) + }) +}) + +describe('runWorktreeRetryAgentLaunch guards', () => { + it('rejects with stale_agent_launch_failure when the durable failure is gone', async () => { + const h = harness({ loadDurableFailure: () => null }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + expect(h.runLaunch).not.toHaveBeenCalled() + }) + + it('rejects with stale_agent_launch_failure when expectedFailureId mismatches', async () => { + const h = harness({ loadDurableFailure: () => durableFailure({ failureId: 'other' }) }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + }) + + it('blocks with launch_state_unknown without mutation when liveness is unknown', async () => { + const h = harness({ resolveRecoveryGate: () => ({ kind: 'launch_state_unknown' }) }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result).toEqual({ status: 'blocked', failure: { code: 'launch_state_unknown' } }) + expect(h.runLaunch).not.toHaveBeenCalled() + }) + + it('blocks with invalid_launch_snapshot while a token-live terminal lacks attribution', async () => { + const h = harness({ resolveRecoveryGate: () => ({ kind: 'invalid_launch_snapshot' }) }) + const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(result).toEqual({ status: 'blocked', failure: { code: 'invalid_launch_snapshot' } }) + }) +}) + +describe('runWorktreeRetryAgentLaunch action resolution', () => { + it('retry-same launches the pinned identity with persisted workspace authority', async () => { + const h = harness() + await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(h.requests[0]).toEqual({ + selection: { kind: 'agent', agent: 'claude' }, + allowEmptyPromptLaunch: true, + sourceRecord: { owner: 'workspace' } + }) + expect(h.runLaunch.mock.calls[0][0].priorFailureId).toBe(FAILURE_ID) + expect(h.registerInFlight).toHaveBeenCalledOnce() + }) + + it('retry-same with no pinned identity launches the host default', async () => { + const h = harness({ loadDurableFailure: () => durableFailure({ requestedAgent: undefined }) }) + await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME) + expect(h.requests[0]).toEqual({ + selection: { kind: 'default' }, + allowEmptyPromptLaunch: true + }) + }) + + it('change-agent launches a live selection with no fallback authority', async () => { + const h = harness() + await runWorktreeRetryAgentLaunch(h.deps, { + ...RETRY_SAME, + action: { kind: 'change-agent', agent: 'codex' } + }) + expect(h.requests[0]).toEqual({ + selection: { kind: 'agent', agent: 'codex' }, + allowEmptyPromptLaunch: true + }) + expect(h.requests[0]).not.toHaveProperty('sourceRecord') + }) +}) diff --git a/src/main/agent-launch/agent-launch-worktree-retry.ts b/src/main/agent-launch/agent-launch-worktree-retry.ts new file mode 100644 index 00000000000..2b97c479e9d --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-retry.ts @@ -0,0 +1,174 @@ +// Host orchestration for `worktree.retryAgentLaunch` (U4). A retry is a fresh +// two-stage launch against an EXISTING worktree, guarded by four ordered checks +// the plan requires and applied here in this exact order: +// 1. Payload-scoped idempotency FIRST — a settled-ledger hit replays the prior +// result, an in-flight hit joins its promise, and a key reuse with a +// DIFFERENT payload returns idempotency_conflict. Ordering it first means a +// double-click after a successful retry replays `launched` instead of +// tripping the (now-cleared) failure guard below. +// 2. `expectedFailureId` anti-race guard against the current durable failure; +// a mismatch (or a cleared/rotated failure) returns stale_agent_launch_failure. +// 3. Server-side recovery-card gating that mirrors the exact state the card +// renders (launch_state_unknown / invalid_launch_snapshot) and blocks WITHOUT +// mutation, so the rejection code always matches the visible card state. +// 4. Only then resolve the action into a launch request and run the shared +// create transaction (which reserves capacity, re-resolves, and settles). +// `expectedFailureId` is an anti-race guard shown in client metadata, never an +// authorization secret. Electron-free and fully injection-based. + +import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request' +import { + canonicalPayloadDigest, + type AgentLaunchOperationStore, + type SettledAgentLaunchOperation +} from './agent-launch-operation-store' + +// The client-safe retry action and tri-state result live in shared so renderer, +// preload, and this host orchestrator type-check against one definition. +export type { + RetryAgentLaunchAction, + WorktreeRetryAgentLaunchResult +} from '../../shared/agent-launch-worktree-recovery' +import type { + RetryAgentLaunchAction, + WorktreeRetryAgentLaunchResult +} from '../../shared/agent-launch-worktree-recovery' + +export type WorktreeRetryAgentLaunchParams = { + /** Owner bucket for the op-store ledger/idempotency joins: worktree id for an + * interactive launch, attempt id for a generic background attempt. */ + scope: string + expectedFailureId: string + // Already validated to canonical lowercase UUID form by the RPC schema. + clientMutationId: string + action: RetryAgentLaunchAction +} + +/** Current recovery state derived from tri-state reconciliation. `retryable` + * means the durable failure is settled and no live terminal contradicts it. */ +export type RetryRecoveryGate = + | { kind: 'retryable' } + | { kind: 'launch_state_unknown' } + | { kind: 'invalid_launch_snapshot' } + +export type WorktreeRetryInFlight = { + payloadDigest: string + promise: Promise +} + +export type WorktreeRetryAgentLaunchDeps = { + operationStore: AgentLaunchOperationStore + /** Idempotency scope key = stable authenticated principal + worktree + + * clientMutationId; survives host restart and client reconnect. */ + idempotencyKeyFor: (clientMutationId: string) => string + /** Ephemeral cross-connection in-flight join; null when none is running. */ + findInFlight: (idempotencyKey: string) => WorktreeRetryInFlight | null + /** Register the launch promise for concurrent joins; the implementation clears + * it when the promise settles. Must be synchronous (no await before it) so the + * find/register pair is atomic against a concurrent double-click. */ + registerInFlight: ( + idempotencyKey: string, + payloadDigest: string, + promise: Promise + ) => void + /** Map an evicted-or-current settled ledger entry to the authorized receipt or + * durable failure it references. */ + resolveSettled: (settled: SettledAgentLaunchOperation) => WorktreeRetryAgentLaunchResult + /** The current durable failure on the worktree, or null when cleared. */ + loadDurableFailure: () => PersistedAgentLaunchFailure | null + /** Server-side recovery-card gate from tri-state reconciliation. */ + resolveRecoveryGate: () => RetryRecoveryGate + /** Run the shared reserve -> execute -> spawn -> settle launch for the resolved + * request; mirrors create's finish and owns prepare-failure classification + * (capacity/deterministic -> blocked, request errors -> rejected). */ + runLaunch: (input: { + request: AgentLaunchSpawnRequest + idempotencyKey: string + clientMutationId: string + payloadDigest: string + priorFailureId: string + }) => Promise +} + +/** Canonical payload for the idempotency digest: the action alone identifies the + * request (retry-same is nullary; change-agent carries its target identity). */ +function canonicalizeAction(action: RetryAgentLaunchAction): unknown { + return action.kind === 'change-agent' + ? { kind: 'change-agent', agent: action.agent } + : { kind: 'retry-same' } +} + +/** Build the launch request from the action. retry-same loads the identity from + * the durable failure and gets persisted-reference authority (a saved + * `workspace` owner, so tombstone/safe-fallback resolution is allowed); + * change-agent is a live selection with NO sourceRecord, so it must resolve a + * currently-existing enabled agent and never gains fallback authority. */ +function buildRetryRequest( + action: RetryAgentLaunchAction, + failure: PersistedAgentLaunchFailure +): AgentLaunchSpawnRequest { + if (action.kind === 'change-agent') { + return { selection: { kind: 'agent', agent: action.agent }, allowEmptyPromptLaunch: true } + } + if (failure.requestedAgent) { + return { + selection: { kind: 'agent', agent: failure.requestedAgent }, + allowEmptyPromptLaunch: true, + sourceRecord: { owner: 'workspace' } + } + } + // A failure with no pinned identity (e.g. no_agent_selected) retries the host + // default, which already carries persisted/default authority. + return { selection: { kind: 'default' }, allowEmptyPromptLaunch: true } +} + +export async function runWorktreeRetryAgentLaunch( + deps: WorktreeRetryAgentLaunchDeps, + params: WorktreeRetryAgentLaunchParams +): Promise { + const idempotencyKey = deps.idempotencyKeyFor(params.clientMutationId) + const payloadDigest = canonicalPayloadDigest(canonicalizeAction(params.action)) + + // 1. Idempotency — settled ledger, then in-flight. Same key + different payload + // is a conflict; same key + same payload replays/joins without a second launch. + const settled = deps.operationStore.findSettledByIdempotencyKey(params.scope, idempotencyKey) + if (settled) { + return settled.payloadDigest === payloadDigest + ? deps.resolveSettled(settled) + : { status: 'rejected', requestError: { code: 'idempotency_conflict' } } + } + const inFlight = deps.findInFlight(idempotencyKey) + if (inFlight) { + return inFlight.payloadDigest === payloadDigest + ? inFlight.promise + : { status: 'rejected', requestError: { code: 'idempotency_conflict' } } + } + + // 2. expectedFailureId guard against the current durable failure. A cleared or + // rotated failure fails here rather than becoming a new launch. + const failure = deps.loadDurableFailure() + if (!failure || failure.failureId !== params.expectedFailureId) { + return { status: 'rejected', requestError: { code: 'stale_agent_launch_failure' } } + } + + // 3. Recovery-card gate — block WITHOUT mutation so the rejection code matches + // the exact state the card renders. + const gate = deps.resolveRecoveryGate() + if (gate.kind !== 'retryable') { + return { status: 'blocked', failure: { code: gate.kind } } + } + + // 4. Resolve the action and run the shared launch. Register the promise before + // returning (no await in between) so a concurrent duplicate joins it. + const request = buildRetryRequest(params.action, failure) + const promise = deps.runLaunch({ + request, + idempotencyKey, + clientMutationId: params.clientMutationId, + payloadDigest, + priorFailureId: params.expectedFailureId + }) + deps.registerInFlight(idempotencyKey, payloadDigest, promise) + return promise +} diff --git a/src/main/agent-launch/agent-launch-worktree-transaction.test.ts b/src/main/agent-launch/agent-launch-worktree-transaction.test.ts new file mode 100644 index 00000000000..c233cb0d550 --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-transaction.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentLaunchOperationStore } from './agent-launch-operation-store' +import { + runWorktreeAgentLaunchTransaction, + type WorktreeAgentLaunchTransactionDeps, + type WorktreeAgentLaunchTransactionParams, + type WorktreePendingAgentLaunch +} from './agent-launch-worktree-transaction' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { + AgentLaunchFailure, + AgentLaunchReceipt, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import type { ExecuteAgentLaunchResult } from './agent-launch-boundary' + +const SNAPSHOT: AgentLaunchSnapshot = { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} + +const PLAN: AgentStartupPlan = { + agent: 'claude', + launchCommand: 'claude', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } +} + +const RECEIPT: AgentLaunchReceipt = { + requestedAgent: 'claude', + baseAgent: 'claude', + notices: [], + launchToken: 'tok-1', + catalogRevision: 3, + telemetry: { agentKind: 'claude-code', usedCustomAgent: false } +} + +type CallLog = string[] + +function makeDeps(overrides: { + snapshot?: AgentLaunchSnapshot | null + spawn?: WorktreeAgentLaunchTransactionDeps['spawn'] + log?: CallLog +}): { + deps: WorktreeAgentLaunchTransactionDeps + operationStore: AgentLaunchOperationStore + settle: ReturnType + persistPending: ReturnType + persistFailure: ReturnType + clearPublicPending: ReturnType +} { + const log = overrides.log ?? [] + const operationStore = new AgentLaunchOperationStore() + const settle = vi.fn((token: string, settlement: string) => { + log.push(`settle:${settlement}:${token}`) + }) + const persistPending = vi.fn((_pending: WorktreePendingAgentLaunch) => { + log.push('persistPending') + }) + const persistFailure = vi.fn(() => { + log.push('persistFailure') + }) + const clearPublicPending = vi.fn(() => { + log.push('clearPublicPending') + }) + const beginPending = operationStore.beginPending.bind(operationStore) + operationStore.beginPending = ((entry) => { + log.push('beginPending') + return beginPending(entry) + }) as typeof operationStore.beginPending + const boundary = { + pendingSnapshotFor: vi.fn(() => + overrides.snapshot === undefined ? SNAPSHOT : overrides.snapshot + ), + settleAgentLaunch: settle + } as unknown as WorktreeAgentLaunchTransactionDeps['boundary'] + const spawn = + overrides.spawn ?? + vi.fn(async (_plan: unknown, receipt: { launchToken: string }) => { + log.push('spawn') + expect(receipt.launchToken).toBe('tok-1') + return { terminalId: 'term-1' } + }) + let failureCounter = 0 + const deps: WorktreeAgentLaunchTransactionDeps = { + boundary, + operationStore, + persistPending, + spawn, + clearPublicPending, + persistFailure, + mintFailureId: () => `fail-${(failureCounter += 1)}`, + now: () => 1000 + } + return { deps, operationStore, settle, persistPending, persistFailure, clearPublicPending } +} + +function params( + execute: () => Promise, + extra?: Partial +): WorktreeAgentLaunchTransactionParams { + return { + operationId: 'op-1', + idempotencyKey: 'idem-1', + scope: 'wt-1', + payloadDigest: 'digest-1', + clientMutationId: null, + requestedAgent: 'claude', + intent: 'interactive', + execute, + ...extra + } +} + +describe('runWorktreeAgentLaunchTransaction', () => { + it('persists pending (public + private) before spawning, then settles launched', async () => { + const log: CallLog = [] + const { deps, operationStore } = makeDeps({ log }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + expect(outcome).toEqual({ status: 'launched', receipt: RECEIPT, terminalId: 'term-1' }) + // Both persistence writes precede the writer; the private write is first. + expect(log.indexOf('beginPending')).toBeLessThan(log.indexOf('spawn')) + expect(log.indexOf('persistPending')).toBeLessThan(log.indexOf('spawn')) + expect(log.indexOf('spawn')).toBeLessThan(log.indexOf('settle:registered:tok-1')) + // Pending is cleared (public + private) and the ledger records launched. + expect(operationStore.getPending('tok-1')).toBeNull() + const settled = operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1') + expect(settled).toMatchObject({ status: 'launched', terminalId: 'term-1', failureId: null }) + }) + + it('keeps only client-safe fields in the public pending metadata', async () => { + const { deps, persistPending } = makeDeps({}) + await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT }), { + priorFailureId: 'prev-fail' + }) + ) + expect(persistPending).toHaveBeenCalledWith({ + operationId: 'op-1', + requestedAgent: 'claude', + priorFailureId: 'prev-fail' + }) + const pending = persistPending.mock.calls[0][0] + expect(Object.keys(pending).sort()).toEqual(['operationId', 'priorFailureId', 'requestedAgent']) + }) + + it('records a durable failure and spawns zero PTYs when execute fails', async () => { + const log: CallLog = [] + const failure: AgentLaunchFailure = { + code: 'agent_configuration_changed', + requestedAgent: 'claude' + } + const { deps, operationStore, persistFailure } = makeDeps({ log }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: false, failure })) + ) + expect(log).not.toContain('spawn') + expect(log).not.toContain('beginPending') + expect(outcome.status).toBe('failed') + if (outcome.status === 'failed') { + expect(outcome.failure).toMatchObject({ + code: 'agent_configuration_changed', + version: 1, + failureId: 'fail-1', + intent: 'interactive', + occurredAt: 1000 + }) + } + expect(persistFailure).toHaveBeenCalledTimes(1) + expect(operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({ + status: 'failed', + failureId: 'fail-1', + terminalId: null + }) + }) + + it('settles failed and records a durable failure when the writer throws', async () => { + const log: CallLog = [] + const spawn = vi.fn(async () => { + log.push('spawn') + throw new Error('pty boom') + }) + const { deps, operationStore, persistFailure } = makeDeps({ log, spawn }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + // Pending was persisted before the writer, then rolled to a failure. + expect(log.indexOf('beginPending')).toBeLessThan(log.indexOf('spawn')) + expect(spawn).toHaveBeenCalledTimes(1) + expect(log).toContain('settle:failed:tok-1') + expect(operationStore.getPending('tok-1')).toBeNull() + expect(outcome.status).toBe('failed') + if (outcome.status === 'failed') { + expect(outcome.failure.code).toBe('spawn_failed') + } + expect(persistFailure).toHaveBeenCalledTimes(1) + }) + + it('performs no owner-state write on a request error', async () => { + const requestError: AgentLaunchRequestError = { code: 'idempotency_conflict' } + const { deps, operationStore, persistFailure, persistPending } = makeDeps({}) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: false, requestError })) + ) + expect(outcome).toEqual({ status: 'request_error', requestError }) + expect(persistFailure).not.toHaveBeenCalled() + expect(persistPending).not.toHaveBeenCalled() + expect(operationStore.settledForScope('wt-1')).toHaveLength(0) + }) + + it('fails closed and spawns nothing when the admitted snapshot is missing', async () => { + const log: CallLog = [] + const { deps, persistFailure } = makeDeps({ log, snapshot: null }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + expect(log).not.toContain('spawn') + expect(log).toContain('settle:failed:tok-1') + expect(outcome.status).toBe('failed') + if (outcome.status === 'failed') { + expect(outcome.failure.code).toBe('invalid_launch_snapshot') + } + expect(persistFailure).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/agent-launch/agent-launch-worktree-transaction.ts b/src/main/agent-launch/agent-launch-worktree-transaction.ts new file mode 100644 index 00000000000..b9393434bcc --- /dev/null +++ b/src/main/agent-launch/agent-launch-worktree-transaction.ts @@ -0,0 +1,197 @@ +// The created-path transaction for a worktree `agentLaunch` (U4). Given the +// stage-2 resolution thunk (executeWorktreeAgentLaunch) and injected persistence/ +// spawn callbacks, it enforces the plan's ordering guarantees exactly: +// 1. resolve+admit (the thunk) — a failure released the reservation already; +// 2. persist the public pending metadata AND the private snapshot/token in ONE +// synchronous write BEFORE the writer, so a crash mid-spawn still self- +// identifies the terminal by token; +// 3. spawn exactly ONE PTY from the resolved plan (token travels inside it); +// 4. settle — registered clears pending + records `launched`; any post-create +// failure keeps the workspace, writes a durable `agentLaunchFailure`, and +// records `failed`. No path spawns a substitute blank terminal (I9). +// A request error performs no owner-state write. Electron-free and injectable. + +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { + AgentLaunchFailure, + AgentLaunchIntentKind, + AgentLaunchReceipt, + AgentLaunchRequestError, + PersistedAgentLaunchFailure +} from '../../shared/agent-launch-contract' +import type { TuiAgent } from '../../shared/types' +import type { AgentLaunchBoundary, ExecuteAgentLaunchResult } from './agent-launch-boundary' +import type { AgentLaunchOperationStore } from './agent-launch-operation-store' + +/** Public pending metadata the caller writes onto WorktreeMeta. The private + * snapshot/token stay in the operation store and never enter this shape. */ +export type WorktreePendingAgentLaunch = { + operationId: string + requestedAgent: TuiAgent + priorFailureId?: string +} + +/** Creates and registers exactly ONE PTY from the resolved plan. The receipt + * carries the launch token (which travels inside the spawn request) plus the + * built-in base agent the terminal binds for process/telemetry keying. Must + * throw on spawn/registration failure so the reservation settles `failed`; a + * returned value means the PTY is registered and names the terminal id. */ +export type WorktreeLaunchSpawn = ( + plan: AgentStartupPlan, + receipt: AgentLaunchReceipt +) => Promise<{ terminalId: string }> + +export type WorktreeAgentLaunchTransactionDeps = { + boundary: AgentLaunchBoundary + operationStore: AgentLaunchOperationStore + /** Public pending metadata write; paired with the private snapshot write in + * the same synchronous transaction, before the writer. */ + persistPending: (pending: WorktreePendingAgentLaunch) => void + spawn: WorktreeLaunchSpawn + /** Clear the public pending metadata after a registered launch. */ + clearPublicPending: () => void + /** Persist the durable failure onto WorktreeMeta.agentLaunchFailure and clear + * any pending metadata. Must be safe to call whether or not pending was + * written (execute-stage vs spawn-stage failure). */ + persistFailure: (failure: PersistedAgentLaunchFailure) => void + mintFailureId: () => string + now?: () => number +} + +export type WorktreeAgentLaunchTransactionParams = { + operationId: string + idempotencyKey: string + scope: string + payloadDigest: string + clientMutationId: string | null + requestedAgent: TuiAgent + intent: AgentLaunchIntentKind + priorFailureId?: string + /** Stage-2 resolution: re-resolve with authoritative paths + pinned identity, + * recheck the digest, and convert the held reservation. Releases the + * reservation itself on any failure. */ + execute: () => Promise +} + +export type WorktreeAgentLaunchOutcome = + | { status: 'launched'; receipt: AgentLaunchReceipt; terminalId: string } + | { status: 'failed'; failure: PersistedAgentLaunchFailure } + | { status: 'request_error'; requestError: AgentLaunchRequestError } + +function persistedFailure( + deps: WorktreeAgentLaunchTransactionDeps, + params: WorktreeAgentLaunchTransactionParams, + failure: AgentLaunchFailure, + nowFn: () => number +): { status: 'failed'; failure: PersistedAgentLaunchFailure } { + const persisted: PersistedAgentLaunchFailure = { + ...failure, + version: 1, + failureId: deps.mintFailureId(), + intent: params.intent, + occurredAt: nowFn() + } + // Keep the workspace; the durable failure card offers Retry/Choose agent. + deps.persistFailure(persisted) + deps.operationStore.recordSettled({ + operationId: params.operationId, + idempotencyKey: params.idempotencyKey, + scope: params.scope, + payloadDigest: params.payloadDigest, + status: 'failed', + terminalId: null, + failureId: persisted.failureId, + settledAt: nowFn() + }) + return { status: 'failed', failure: persisted } +} + +/** Run the created-path transaction. The git worktree already exists; a failure + * here NEVER rolls it back and NEVER spawns a substitute shell. */ +export async function runWorktreeAgentLaunchTransaction( + deps: WorktreeAgentLaunchTransactionDeps, + params: WorktreeAgentLaunchTransactionParams +): Promise { + const nowFn = deps.now ?? Date.now + const execution = await params.execute() + if (!execution.ok) { + if ('requestError' in execution) { + // Request errors perform no owner-state write; the reservation is already + // released by execute. + return { status: 'request_error', requestError: execution.requestError } + } + return persistedFailure(deps, params, execution.failure, nowFn) + } + const { plan, receipt } = execution + const snapshot = deps.boundary.pendingSnapshotFor(receipt.launchToken) + if (!snapshot) { + // The admitted token must carry a private snapshot; a missing one cannot be + // attributed, so fail closed rather than spawn an unattributable terminal. + deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed') + return persistedFailure( + deps, + params, + { + code: 'invalid_launch_snapshot', + requestedAgent: receipt.requestedAgent, + baseAgent: receipt.baseAgent + }, + nowFn + ) + } + + // ONE persistence transaction before the writer: private snapshot/token first, + // then the client-safe pending metadata. Both synchronous so no mutation lands + // between them and a mid-spawn crash still resolves via the persisted token. + deps.operationStore.beginPending({ + operationId: params.operationId, + idempotencyKey: params.idempotencyKey, + scope: params.scope, + clientMutationId: params.clientMutationId, + payloadDigest: params.payloadDigest, + launchToken: receipt.launchToken, + intent: params.intent, + snapshot + }) + deps.persistPending({ + operationId: params.operationId, + requestedAgent: receipt.requestedAgent, + ...(params.priorFailureId ? { priorFailureId: params.priorFailureId } : {}) + }) + + let terminalId: string + try { + const spawned = await deps.spawn(plan, receipt) + terminalId = spawned.terminalId + } catch { + deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed') + deps.operationStore.clearPending(receipt.launchToken) + return persistedFailure( + deps, + params, + { + code: 'spawn_failed', + requestedAgent: receipt.requestedAgent, + baseAgent: receipt.baseAgent + }, + nowFn + ) + } + + // Registered: move attribution into the boundary's retained handoff, clear the + // pending (public + private), and append the settled `launched` ledger entry. + deps.boundary.settleAgentLaunch(receipt.launchToken, 'registered') + deps.operationStore.clearPending(receipt.launchToken) + deps.clearPublicPending() + deps.operationStore.recordSettled({ + operationId: params.operationId, + idempotencyKey: params.idempotencyKey, + scope: params.scope, + payloadDigest: params.payloadDigest, + status: 'launched', + terminalId, + failureId: null, + settledAt: nowFn() + }) + return { status: 'launched', receipt, terminalId } +} diff --git a/src/main/agent-launch/agent-reference-mutations.test.ts b/src/main/agent-launch/agent-reference-mutations.test.ts new file mode 100644 index 00000000000..82de2b4d966 --- /dev/null +++ b/src/main/agent-launch/agent-reference-mutations.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import type { + CustomTuiAgent, + CustomTuiAgentId, + GlobalSettings, + TerminalAgentQuickCommand +} from '../../shared/types' +import { normalizeAgentCatalog } from '../../shared/custom-tui-agents' +import type { AgentReferenceMutation } from '../../shared/agent-reference-snapshot' +import { applyAgentReferenceMutation } from './agent-reference-mutations' + +const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' +const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' + +function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +function liveAgent(overrides: Partial = {}): CustomTuiAgent { + return { + id: customId('codex'), + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +function settingsWith(overrides: Partial = {}): GlobalSettings { + return { + defaultTuiAgent: 'auto', + disabledTuiAgents: [], + customTuiAgents: [], + deletedCustomTuiAgents: [], + agentReferenceRevision: 3, + terminalQuickCommands: [], + ...overrides + } as GlobalSettings +} + +function apply(settings: GlobalSettings, mutation: AgentReferenceMutation, expected = 3) { + const catalog = normalizeAgentCatalog({ + customTuiAgents: settings.customTuiAgents, + deletedCustomTuiAgents: settings.deletedCustomTuiAgents, + disabledTuiAgents: settings.disabledTuiAgents, + defaultTuiAgent: settings.defaultTuiAgent + }).catalog + return applyAgentReferenceMutation({ + settings, + request: { expectedReferenceRevision: expected, mutation }, + currentReferenceRevision: settings.agentReferenceRevision ?? 1, + catalog + }) +} + +function agentQuickCommand( + overrides: Partial = {} +): TerminalAgentQuickCommand { + return { + id: 'qc-1', + label: 'Fix tests', + action: 'agent-prompt', + agent: 'codex', + prompt: 'fix the tests', + ...overrides + } +} + +describe('reference revision gating', () => { + it('rejects a stale expectedReferenceRevision without writing', () => { + const result = apply(settingsWith(), { kind: 'quick-command-delete', id: 'x' }, 2) + expect(result).toEqual({ ok: false, code: 'reference_revision_conflict' }) + }) +}) + +describe('quick-command stale-reference write rule', () => { + const stale = customId('codex', UUID_B) // no live definition, no tombstone needed here + const storedCommand = agentQuickCommand({ agent: stale }) + + it('preserves the exact stored stale reference when resubmitted unchanged', () => { + const settings = settingsWith({ terminalQuickCommands: [storedCommand] }) + const result = apply(settings, { + kind: 'quick-command-save', + command: { ...storedCommand, label: 'Renamed', agent: stale } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const saved = result.patch.terminalQuickCommands?.[0] as TerminalAgentQuickCommand + expect(saved.label).toBe('Renamed') + expect(saved.agent).toBe(stale) + expect(result.patch.agentReferenceRevision).toBe(4) + }) + + it('rejects a changed agent that is not a current enabled live identity', () => { + const settings = settingsWith({ terminalQuickCommands: [storedCommand] }) + const unknown = apply(settings, { + kind: 'quick-command-save', + command: { ...storedCommand, agent: customId('claude', UUID_A) } + }) + expect(unknown).toMatchObject({ + ok: false, + code: 'invalid_agent_reference', + owner: 'quick-command', + reason: 'unknown_agent' + }) + + const live = liveAgent() + const disabledSettings = settingsWith({ + terminalQuickCommands: [storedCommand], + customTuiAgents: [live], + disabledTuiAgents: [live.id] + }) + const disabled = apply(disabledSettings, { + kind: 'quick-command-save', + command: { ...storedCommand, agent: live.id } + }) + expect(disabled).toMatchObject({ ok: false, reason: 'disabled_agent' }) + + const baseDisabledSettings = settingsWith({ + terminalQuickCommands: [storedCommand], + customTuiAgents: [live], + disabledTuiAgents: ['codex'] + }) + const baseDisabled = apply(baseDisabledSettings, { + kind: 'quick-command-save', + command: { ...storedCommand, agent: live.id } + }) + expect(baseDisabled).toMatchObject({ ok: false, reason: 'disabled_agent' }) + }) + + it('accepts a changed agent that is an enabled live identity', () => { + const live = liveAgent() + const settings = settingsWith({ + terminalQuickCommands: [storedCommand], + customTuiAgents: [live] + }) + const result = apply(settings, { + kind: 'quick-command-save', + command: { ...storedCommand, agent: live.id } + }) + expect(result.ok).toBe(true) + }) + + it('a new row cannot mint fallback authority from a stale id', () => { + // The same stale id that is preserved on its own row is rejected when a + // client echoes it into a different/new row. + const settings = settingsWith({ terminalQuickCommands: [storedCommand] }) + const result = apply(settings, { + kind: 'quick-command-save', + command: agentQuickCommand({ id: 'qc-new', agent: stale }) + }) + expect(result).toMatchObject({ ok: false, reason: 'unknown_agent' }) + }) + + it('deletes and reorders without touching agent references', () => { + const other = agentQuickCommand({ id: 'qc-2', agent: 'claude' }) + const settings = settingsWith({ terminalQuickCommands: [storedCommand, other] }) + const removed = apply(settings, { kind: 'quick-command-delete', id: 'qc-1' }) + expect(removed.ok).toBe(true) + if (!removed.ok) { + return + } + expect(removed.patch.terminalQuickCommands).toEqual([other]) + + const reordered = apply(settings, { + kind: 'quick-commands-reorder', + orderedIds: ['qc-2', 'qc-1'] + }) + expect(reordered.ok).toBe(true) + if (!reordered.ok) { + return + } + expect(reordered.patch.terminalQuickCommands?.map((command) => command.id)).toEqual([ + 'qc-2', + 'qc-1' + ]) + + const badReorder = apply(settings, { + kind: 'quick-commands-reorder', + orderedIds: ['qc-2'] + }) + expect(badReorder).toMatchObject({ ok: false, code: 'invalid_reference_field' }) + }) +}) + +describe('commit-message and source-control field-level rule', () => { + const stale = customId('codex', UUID_B) + + it('preserves a stored stale agentId when omitted or resubmitted; clears explicitly', () => { + const settings = settingsWith({ + commitMessageAi: { + enabled: true, + agentId: stale, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } + }) + const omitted = apply(settings, { + kind: 'commit-message-update', + changes: { enabled: false } + }) + expect(omitted.ok).toBe(true) + if (!omitted.ok) { + return + } + expect(omitted.patch.commitMessageAi?.agentId).toBe(stale) + expect(omitted.patch.commitMessageAi?.enabled).toBe(false) + + const resubmitted = apply(settings, { + kind: 'commit-message-update', + changes: { agentId: stale } + }) + expect(resubmitted.ok).toBe(true) + + const cleared = apply(settings, { + kind: 'commit-message-update', + changes: { agentId: null } + }) + expect(cleared.ok).toBe(true) + if (!cleared.ok) { + return + } + expect(cleared.patch.commitMessageAi?.agentId).toBeNull() + }) + + it('allows the custom-command sentinel and enabled identities; rejects unknown ids', () => { + const settings = settingsWith({ + commitMessageAi: { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } + }) + expect( + apply(settings, { kind: 'commit-message-update', changes: { agentId: 'custom' } }).ok + ).toBe(true) + expect( + apply(settings, { kind: 'commit-message-update', changes: { agentId: 'claude' } }).ok + ).toBe(true) + expect( + apply(settings, { kind: 'commit-message-update', changes: { agentId: stale } }) + ).toMatchObject({ ok: false, reason: 'unknown_agent' }) + }) + + it('applies the row-level rule to source-control action recipes', () => { + const live = liveAgent() + const settings = settingsWith({ + customTuiAgents: [live], + sourceControlAi: { + enabled: true, + agentId: null, + actions: { + 'commit-message': { agentId: stale, commandInputTemplate: 'x' } + }, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand: '', + instructionsByOperation: {} + } as GlobalSettings['sourceControlAi'] + }) + // Unrelated action field saves while the stale row reference is preserved. + const preserved = apply(settings, { + kind: 'source-control-update', + changes: { + actions: { 'commit-message': { commandInputTemplate: 'y' } } + } as Partial> + }) + expect(preserved.ok).toBe(true) + if (!preserved.ok) { + return + } + const action = preserved.patch.sourceControlAi?.actions?.['commit-message'] as { + agentId?: unknown + commandInputTemplate?: unknown + } + expect(action.agentId).toBe(stale) + expect(action.commandInputTemplate).toBe('y') + + // Changing the row to a live enabled identity works; unknown is rejected. + const changed = apply(settings, { + kind: 'source-control-update', + changes: { + actions: { 'commit-message': { agentId: live.id } } + } as Partial> + }) + expect(changed.ok).toBe(true) + + const rejected = apply(settings, { + kind: 'source-control-update', + changes: { + actions: { 'commit-message': { agentId: customId('claude', UUID_A) } } + } as Partial> + }) + expect(rejected).toMatchObject({ ok: false, owner: 'source-control-recipe' }) + }) +}) diff --git a/src/main/agent-launch/agent-reference-mutations.ts b/src/main/agent-launch/agent-reference-mutations.ts new file mode 100644 index 00000000000..d7cffb77543 --- /dev/null +++ b/src/main/agent-launch/agent-reference-mutations.ts @@ -0,0 +1,320 @@ +// Owner-specific agent-reference mutation engine (terminal quick commands, +// commit-message agent choice, Source Control AI settings). Enforces the +// field-level stale-reference write rule so unrelated edits save while a proven +// stale reference is preserved, and a *changed* agent must be a currently +// effectively enabled live identity. + +import type { + CommitMessageAiSettings, + GlobalSettings, + TerminalQuickCommand, + TuiAgent +} from '../../shared/types' +import type { SourceControlAiSettings } from '../../shared/source-control-ai-types' +import type { AgentReferenceMutationRequest } from '../../shared/agent-reference-snapshot' +import { CUSTOM_AGENT_ID } from '../../shared/commit-message-agent-spec' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import { isCustomTuiAgentId, type AgentCatalog } from '../../shared/custom-tui-agents' + +export type AgentReferenceMutationError = { + ok: false + code: + | 'reference_revision_conflict' + | 'invalid_agent_reference' + | 'invalid_reference_field' + | 'agent_reference_payload_too_large' + owner?: 'quick-command' | 'commit-message' | 'source-control-recipe' + field?: string + reason?: 'unknown_agent' | 'disabled_agent' | 'bounds' | 'conflict' +} + +export type AgentReferenceMutationApplication = + | { + ok: true + patch: Partial + newReferenceRevision: number + } + | AgentReferenceMutationError + +/** A changed agent reference must resolve to a currently effectively enabled + * live identity: enabled built-in, or live custom whose own id and base are + * both enabled. Stale/tombstoned ids never enter through a *change*. */ +function isEffectivelyEnabledLiveIdentity(agent: TuiAgent, catalog: AgentCatalog): boolean { + if (isBuiltInTuiAgent(agent)) { + return !catalog.disabledAgents.has(agent) + } + if (!isCustomTuiAgentId(agent)) { + return false + } + const definition = catalog.liveById.get(agent) + if (!definition) { + return false + } + return !catalog.disabledAgents.has(agent) && !catalog.disabledAgents.has(definition.baseAgent) +} + +type AgentFieldDecision = + | { ok: true; value: TuiAgent | typeof CUSTOM_AGENT_ID | null | undefined } + | { ok: false; reason: 'unknown_agent' | 'disabled_agent' } + +/** Field-level rule: undefined preserves stored; the exact stored value (even a + * stale custom id) is a no-op; null clears; anything else must be enabled+live + * (or the commit-message 'custom' sentinel where allowed). */ +function decideAgentField(args: { + incoming: unknown + stored: unknown + catalog: AgentCatalog + allowCustomSentinel: boolean +}): AgentFieldDecision { + const { incoming, stored, catalog, allowCustomSentinel } = args + if (incoming === undefined) { + return { ok: true, value: undefined } + } + if (incoming === null) { + return { ok: true, value: null } + } + if (incoming === stored) { + return { ok: true, value: stored as TuiAgent } + } + if (allowCustomSentinel && incoming === CUSTOM_AGENT_ID) { + return { ok: true, value: CUSTOM_AGENT_ID } + } + if (typeof incoming !== 'string') { + return { ok: false, reason: 'unknown_agent' } + } + if (isBuiltInTuiAgent(incoming)) { + return catalog.disabledAgents.has(incoming) + ? { ok: false, reason: 'disabled_agent' } + : { ok: true, value: incoming } + } + if (isCustomTuiAgentId(incoming)) { + if (!catalog.liveById.has(incoming)) { + return { ok: false, reason: 'unknown_agent' } + } + return isEffectivelyEnabledLiveIdentity(incoming, catalog) + ? { ok: true, value: incoming } + : { ok: false, reason: 'disabled_agent' } + } + return { ok: false, reason: 'unknown_agent' } +} + +export type ApplyAgentReferenceMutationArgs = { + settings: GlobalSettings + request: AgentReferenceMutationRequest + currentReferenceRevision: number + catalog: AgentCatalog +} + +export function applyAgentReferenceMutation( + args: ApplyAgentReferenceMutationArgs +): AgentReferenceMutationApplication { + const { settings, request, currentReferenceRevision, catalog } = args + if (request.expectedReferenceRevision !== currentReferenceRevision) { + return { ok: false, code: 'reference_revision_conflict' } + } + const newReferenceRevision = currentReferenceRevision + 1 + const mutation = request.mutation + + switch (mutation.kind) { + case 'quick-command-save': { + const incoming = mutation.command + if ( + !incoming || + typeof incoming !== 'object' || + typeof incoming.id !== 'string' || + incoming.id.length === 0 || + typeof incoming.label !== 'string' + ) { + return { + ok: false, + code: 'invalid_reference_field', + owner: 'quick-command', + reason: 'bounds' + } + } + const commands = settings.terminalQuickCommands ?? [] + const existing = commands.find((command) => command.id === incoming.id) + let toStore: TerminalQuickCommand = incoming + if (incoming.action === 'agent-prompt') { + const storedAgent = + existing && existing.action === 'agent-prompt' ? existing.agent : undefined + const decision = decideAgentField({ + incoming: incoming.agent, + stored: storedAgent, + catalog, + allowCustomSentinel: false + }) + if (!decision.ok) { + return { + ok: false, + code: 'invalid_agent_reference', + owner: 'quick-command', + field: 'agent', + reason: decision.reason + } + } + // An agent-prompt quick command cannot exist without an agent: an + // omitted field keeps the stored reference; there is nothing to clear to. + const agent = decision.value === undefined ? storedAgent : decision.value + if (agent === null || agent === undefined || agent === CUSTOM_AGENT_ID) { + return { + ok: false, + code: 'invalid_agent_reference', + owner: 'quick-command', + field: 'agent', + reason: 'unknown_agent' + } + } + toStore = { ...incoming, agent } + } + const next = existing + ? commands.map((command) => (command.id === incoming.id ? toStore : command)) + : [...commands, toStore] + return { + ok: true, + patch: { terminalQuickCommands: next, agentReferenceRevision: newReferenceRevision }, + newReferenceRevision + } + } + case 'quick-command-delete': { + const commands = settings.terminalQuickCommands ?? [] + const next = commands.filter((command) => command.id !== mutation.id) + return { + ok: true, + patch: { terminalQuickCommands: next, agentReferenceRevision: newReferenceRevision }, + newReferenceRevision + } + } + case 'quick-commands-reorder': { + const commands = settings.terminalQuickCommands ?? [] + const byId = new Map(commands.map((command) => [command.id, command])) + if ( + mutation.orderedIds.length !== commands.length || + mutation.orderedIds.some((id) => !byId.has(id)) || + new Set(mutation.orderedIds).size !== mutation.orderedIds.length + ) { + return { + ok: false, + code: 'invalid_reference_field', + owner: 'quick-command', + reason: 'conflict' + } + } + const next = mutation.orderedIds.map((id) => byId.get(id) as TerminalQuickCommand) + return { + ok: true, + patch: { terminalQuickCommands: next, agentReferenceRevision: newReferenceRevision }, + newReferenceRevision + } + } + case 'commit-message-update': { + const stored = settings.commitMessageAi + const decision = decideAgentField({ + incoming: 'agentId' in mutation.changes ? mutation.changes.agentId : undefined, + stored: stored?.agentId ?? null, + catalog, + allowCustomSentinel: true + }) + if (!decision.ok) { + return { + ok: false, + code: 'invalid_agent_reference', + owner: 'commit-message', + field: 'agentId', + reason: decision.reason + } + } + const next: CommitMessageAiSettings = { + ...(stored as CommitMessageAiSettings), + ...mutation.changes, + agentId: + decision.value === undefined + ? (stored?.agentId ?? null) + : (decision.value as CommitMessageAiSettings['agentId']) + } + return { + ok: true, + patch: { commitMessageAi: next, agentReferenceRevision: newReferenceRevision }, + newReferenceRevision + } + } + case 'source-control-update': { + const stored = settings.sourceControlAi + const decision = decideAgentField({ + incoming: 'agentId' in mutation.changes ? mutation.changes.agentId : undefined, + stored: stored?.agentId ?? null, + catalog, + allowCustomSentinel: true + }) + if (!decision.ok) { + return { + ok: false, + code: 'invalid_agent_reference', + owner: 'source-control-recipe', + field: 'agentId', + reason: decision.reason + } + } + // Per-action recipes apply the same field-level rule row by row. + let nextActions = stored?.actions + if (mutation.changes.actions !== undefined) { + const incomingActions = mutation.changes.actions ?? {} + const merged: NonNullable = { + ...stored?.actions + } + for (const [actionId, incomingAction] of Object.entries(incomingActions)) { + const storedAction = stored?.actions?.[actionId as keyof typeof merged] + if (incomingAction === undefined) { + continue + } + const storedAgent = + storedAction && typeof storedAction === 'object' && 'agentId' in storedAction + ? (storedAction as { agentId?: unknown }).agentId + : undefined + const incomingAgent = + incomingAction && typeof incomingAction === 'object' && 'agentId' in incomingAction + ? (incomingAction as { agentId?: unknown }).agentId + : undefined + const actionDecision = decideAgentField({ + incoming: incomingAgent, + stored: storedAgent ?? null, + catalog, + allowCustomSentinel: true + }) + if (!actionDecision.ok) { + return { + ok: false, + code: 'invalid_agent_reference', + owner: 'source-control-recipe', + field: actionId, + reason: actionDecision.reason + } + } + merged[actionId as keyof typeof merged] = { + ...(storedAction as object), + ...(incomingAction as object), + agentId: + actionDecision.value === undefined + ? ((storedAgent ?? null) as TuiAgent | 'custom' | null) + : (actionDecision.value as TuiAgent | 'custom' | null) + } as NonNullable[keyof typeof merged] + } + nextActions = merged + } + const next: SourceControlAiSettings = { + ...(stored as SourceControlAiSettings), + ...mutation.changes, + agentId: + decision.value === undefined + ? (stored?.agentId ?? null) + : (decision.value as SourceControlAiSettings['agentId']), + ...(nextActions !== undefined ? { actions: nextActions } : {}) + } + return { + ok: true, + patch: { sourceControlAi: next, agentReferenceRevision: newReferenceRevision }, + newReferenceRevision + } + } + } +} diff --git a/src/main/agent-launch/agent-session-launch-registration.test.ts b/src/main/agent-launch/agent-session-launch-registration.test.ts new file mode 100644 index 00000000000..3a642788968 --- /dev/null +++ b/src/main/agent-launch/agent-session-launch-registration.test.ts @@ -0,0 +1,118 @@ +// U5: the shared spawn-success registration helper stages the admitted snapshot +// (read host-private from the boundary, never the client receipt) keyed by launch +// token, and no-ops when the snapshot is gone or the worktree id is empty. +import { describe, expect, it } from 'vitest' +import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { registerHostSessionLaunch } from './agent-session-launch-registration' +import type { AgentLaunchBoundary } from './agent-launch-boundary' + +function snapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'custom-agent:claude:reviewer', + baseAgent: 'claude', + displayLabel: 'Reviewer', + mode: 'custom', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +const RECEIPT: AgentLaunchReceipt = { + requestedAgent: 'custom-agent:claude:reviewer', + baseAgent: 'claude', + notices: [], + launchToken: 'token-a', + catalogRevision: 1, + telemetry: { agentKind: 'claude-code', usedCustomAgent: true } +} + +/** A boundary stub exposing the two snapshot accessors the helper reads. `where` + * selects whether the snapshot is post-settle (retained) or mid-spawn (pending). */ +function boundaryWith( + snap: AgentLaunchSnapshot | null, + where: 'retained' | 'pending' = 'retained' +): AgentLaunchBoundary { + const hit = (token: string): AgentLaunchSnapshot | null => (token === 'token-a' ? snap : null) + return { + retainedFor: (token: string) => + where === 'retained' && hit(token) ? { snapshot: hit(token) } : null, + pendingSnapshotFor: (token: string) => (where === 'pending' ? hit(token) : null) + } as unknown as AgentLaunchBoundary +} + +const OWNERSHIP = { worktreeId: 'wt-1', baseAgent: 'claude' as const, providerSessionId: 'sess-1' } + +describe('registerHostSessionLaunch', () => { + it('stages the retained snapshot so a later hook bind makes it resumable', () => { + const store = new AgentSessionRecordStore() + registerHostSessionLaunch({ + boundary: boundaryWith(snapshot()), + store, + launchToken: 'token-a', + worktreeId: 'wt-1', + receipt: RECEIPT, + paneKey: 'pane-a', + terminalId: 'term-a' + }) + // Staged, not yet resumable, until the hook binds a provider session. + expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull() + store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' }) + const record = store.resolveByOwnershipKey(OWNERSHIP) + expect(record?.launchSnapshot).toEqual(snapshot()) + expect(record?.requestedAgent).toBe('custom-agent:claude:reviewer') + }) + + it('stages a mid-spawn launch from the pending admission snapshot (pre-settle)', () => { + const store = new AgentSessionRecordStore() + registerHostSessionLaunch({ + boundary: boundaryWith(snapshot(), 'pending'), + store, + launchToken: 'token-a', + worktreeId: 'wt-1', + receipt: RECEIPT + }) + expect( + store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' }) + ).not.toBeNull() + expect(store.resolveByOwnershipKey(OWNERSHIP)?.launchSnapshot).toEqual(snapshot()) + }) + + it('no-ops when the admitted snapshot is no longer retained', () => { + const store = new AgentSessionRecordStore() + registerHostSessionLaunch({ + boundary: boundaryWith(null), + store, + launchToken: 'token-a', + worktreeId: 'wt-1', + receipt: RECEIPT + }) + expect( + store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' }) + ).toBeNull() + }) + + it('no-ops for an empty worktree id (never resolvable by an ownership key)', () => { + const store = new AgentSessionRecordStore() + registerHostSessionLaunch({ + boundary: boundaryWith(snapshot()), + store, + launchToken: 'token-a', + worktreeId: '', + receipt: RECEIPT + }) + expect( + store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' }) + ).toBeNull() + }) +}) diff --git a/src/main/agent-launch/agent-session-launch-registration.ts b/src/main/agent-launch/agent-session-launch-registration.ts new file mode 100644 index 00000000000..3690d283c4f --- /dev/null +++ b/src/main/agent-launch/agent-session-launch-registration.ts @@ -0,0 +1,51 @@ +// Shared spawn-success registration of a launch's host-private resume attribution +// (U5, §577). Every launch surface (desktop pty:spawn, mobile/paired runtime +// terminal create, worktree-create agent terminal) calls this right after it +// settles its admission token 'registered', so the immutable snapshot + token are +// staged in the session record store keyed by launch token. A later provider hook +// binds the session and promotes the staging to a durable, resumable record. +// +// The snapshot is read from the boundary's retained admitted record — it is +// host-private and never travels on the client receipt. + +import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract' +import type { AgentLaunchBoundary } from './agent-launch-boundary' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +export type RegisterHostSessionLaunchArgs = { + boundary: AgentLaunchBoundary + store: AgentSessionRecordStore + launchToken: string + worktreeId: string + receipt: AgentLaunchReceipt + /** Optional attribution metadata: a stable pane key lets a pane teardown drop + * unbound staging. Surfaces without one omit it; the token drives bind. */ + paneKey?: string + terminalId?: string +} + +/** Stage the resume attribution for a freshly launched agent. Works whether the + * caller has already settled the admission token 'registered' (retained record) + * or is still mid-spawn (pending admission snapshot). A no-op when the admitted + * snapshot is gone (e.g. the launch was never admitted) or the worktree id is + * empty — a record with no worktree could never be resolved by an ownership key. */ +export function registerHostSessionLaunch(args: RegisterHostSessionLaunchArgs): void { + if (!args.worktreeId) { + return + } + const launchSnapshot = + args.boundary.retainedFor(args.launchToken)?.snapshot ?? + args.boundary.pendingSnapshotFor(args.launchToken) + if (!launchSnapshot) { + return + } + args.store.register({ + ...(args.paneKey ? { paneKey: args.paneKey } : {}), + ...(args.terminalId ? { terminalId: args.terminalId } : {}), + worktreeId: args.worktreeId, + requestedAgent: args.receipt.requestedAgent, + baseAgent: args.receipt.baseAgent, + launchSnapshot, + launchToken: args.launchToken + }) +} diff --git a/src/main/agent-launch/agent-session-record-store-host.ts b/src/main/agent-launch/agent-session-record-store-host.ts new file mode 100644 index 00000000000..b9e52ad50e6 --- /dev/null +++ b/src/main/agent-launch/agent-session-record-store-host.ts @@ -0,0 +1,15 @@ +// Host-wide singleton session record store. One instance per host so every launch +// surface registers attribution and every resume/fork resolves against the same +// private records. Durable persistence attaches at boot; the in-memory instance +// backs registration/bind/resolve before that. + +import { AgentSessionRecordStore } from './agent-session-record-store' + +let store: AgentSessionRecordStore | null = null + +export function getHostAgentSessionRecordStore(): AgentSessionRecordStore { + if (!store) { + store = new AgentSessionRecordStore() + } + return store +} diff --git a/src/main/agent-launch/agent-session-record-store-persistence.test.ts b/src/main/agent-launch/agent-session-record-store-persistence.test.ts new file mode 100644 index 00000000000..2a30bbf2364 --- /dev/null +++ b/src/main/agent-launch/agent-session-record-store-persistence.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' + +// The module imports `safeStorage` at top for its Electron cipher factory; these +// tests inject their own cipher, so a bare stub keeps the import resolvable. +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value, 'utf-8'), + decryptString: (value: Buffer) => value.toString('utf-8') + } +})) + +import type { HostSessionLaunchRecord } from './agent-session-record-store' +import { + agentSessionRecordStorePath, + decodeAgentSessionRecordStore, + encodeAgentSessionRecordStore, + loadAgentSessionRecordStoreState, + writeAgentSessionRecordStoreState, + type AgentSessionRecordCipher +} from './agent-session-record-store-persistence' + +function reversibleCipher(available: boolean): AgentSessionRecordCipher { + return { + available: () => available, + encrypt: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decrypt: (ciphertext) => ciphertext.toString('utf-8').replace(/^enc:/, '') + } +} + +const snapshot: AgentLaunchSnapshot = { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: { SECRET_TOKEN: 'do-not-leak' }, + capturedEnvPolicy: 'full', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} + +const record: HostSessionLaunchRecord = { + worktreeId: 'wt-1', + requestedAgent: 'custom-agent:claude:reviewer', + baseAgent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + launchSnapshot: snapshot, + launchToken: 'secret-token', + registeredAt: 1, + updatedAt: 2 +} + +describe('agent-session-record-store persistence envelope', () => { + it('encrypts the records section and round-trips through decode', () => { + const cipher = reversibleCipher(true) + const encoded = encodeAgentSessionRecordStore({ records: [record] }, cipher) + expect(encoded.records.format).toBe('electron-safe-storage-v1') + const decoded = decodeAgentSessionRecordStore(encoded, cipher) + expect(decoded.records).toEqual([record]) + }) + + it('falls back to hardened plaintext when encryption is unavailable', () => { + const cipher = reversibleCipher(false) + const encoded = encodeAgentSessionRecordStore({ records: [record] }, cipher) + expect(encoded.records.format).toBe('plaintext-v1') + expect(decodeAgentSessionRecordStore(encoded, cipher).records).toEqual([record]) + }) + + it('drops records rather than blocking boot when the cipher is unavailable at decode', () => { + const encoded = encodeAgentSessionRecordStore({ records: [record] }, reversibleCipher(true)) + // Keychain reset: encrypted section can no longer be read. + expect(decodeAgentSessionRecordStore(encoded, reversibleCipher(false)).records).toEqual([]) + }) + + it('returns empty state for an unknown version', () => { + expect(decodeAgentSessionRecordStore({ version: 9 }, reversibleCipher(true))).toEqual({ + records: [] + }) + }) +}) + +describe('agent-session-record-store persistence file I/O', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'agent-session-records-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('writes an encrypted file whose bytes do not contain the plaintext secret', () => { + const path = agentSessionRecordStorePath(dir) + const cipher = reversibleCipher(true) + writeAgentSessionRecordStoreState(path, { records: [record] }, cipher) + const raw = readFileSync(path, 'utf-8') + expect(raw).not.toContain('do-not-leak') + expect(raw).not.toContain('secret-token') + expect(loadAgentSessionRecordStoreState(path, cipher).records).toEqual([record]) + }) + + it('returns empty state when the file is absent', () => { + expect( + loadAgentSessionRecordStoreState(agentSessionRecordStorePath(dir), reversibleCipher(true)) + ).toEqual({ records: [] }) + }) +}) diff --git a/src/main/agent-launch/agent-session-record-store-persistence.ts b/src/main/agent-launch/agent-session-record-store-persistence.ts new file mode 100644 index 00000000000..26327936d40 --- /dev/null +++ b/src/main/agent-launch/agent-session-record-store-persistence.ts @@ -0,0 +1,150 @@ +// Host-private durable persistence for the session record store (U5). Records +// carry the immutable launch snapshot (resolved argv + admitted agent env) and, +// for legacy handoffs, the opaque replay config — both secret-bearing — plus the +// launch token, so the whole record set is encrypted at rest via Electron +// safeStorage (the secret-settings standard), with a permission-hardened plaintext +// fallback only when OS-backed encryption is unavailable. Written with the same +// atomic tmp+rename discipline as the launch-operation store. The encode/decode +// core takes an injected cipher so the envelope round-trip is testable without +// Electron. This file is never client-synced. + +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { safeStorage } from 'electron' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import type { + AgentSessionRecordStoreDurableState, + HostSessionLaunchRecord +} from './agent-session-record-store' +import { getHostAgentSessionRecordStore } from './agent-session-record-store-host' + +const STORE_FILENAME = 'agent-session-records.json' + +export function agentSessionRecordStorePath(userDataPath: string): string { + return join(userDataPath, STORE_FILENAME) +} + +/** Crypto boundary for the encrypted records section. Injected so the envelope + * round-trip is unit-testable without an Electron/OS keychain. */ +export type AgentSessionRecordCipher = { + available: () => boolean + encrypt: (plaintext: string) => Buffer + decrypt: (ciphertext: Buffer) => string +} + +export function electronSafeStorageCipher(): AgentSessionRecordCipher { + return { + available: () => safeStorage.isEncryptionAvailable(), + encrypt: (plaintext) => safeStorage.encryptString(plaintext), + decrypt: (ciphertext) => safeStorage.decryptString(ciphertext) + } +} + +type PersistedRecordsSection = + | { format: 'electron-safe-storage-v1'; ciphertext: string } + | { format: 'plaintext-v1'; records: HostSessionLaunchRecord[] } + +type PersistedFile = { + version: 1 + records: PersistedRecordsSection +} + +export function encodeAgentSessionRecordStore( + state: AgentSessionRecordStoreDurableState, + cipher: AgentSessionRecordCipher +): PersistedFile { + const records = [...state.records] + const section: PersistedRecordsSection = cipher.available() + ? { + format: 'electron-safe-storage-v1', + ciphertext: cipher.encrypt(JSON.stringify(records)).toString('base64') + } + : { format: 'plaintext-v1', records } + return { version: 1, records: section } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeRecords( + section: unknown, + cipher: AgentSessionRecordCipher +): HostSessionLaunchRecord[] { + if (!isRecord(section)) { + return [] + } + if (section.format === 'plaintext-v1' && Array.isArray(section.records)) { + return section.records as HostSessionLaunchRecord[] + } + if ( + section.format === 'electron-safe-storage-v1' && + typeof section.ciphertext === 'string' && + cipher.available() + ) { + // A decrypt failure (keychain reset) drops only the records, never blocks + // boot: those sessions then require an explicit current-settings relaunch + // rather than a mis-attributed replay. + const parsed = JSON.parse(cipher.decrypt(Buffer.from(section.ciphertext, 'base64'))) + return Array.isArray(parsed) ? (parsed as HostSessionLaunchRecord[]) : [] + } + return [] +} + +export function decodeAgentSessionRecordStore( + raw: unknown, + cipher: AgentSessionRecordCipher +): AgentSessionRecordStoreDurableState { + if (!isRecord(raw) || raw.version !== 1) { + return { records: [] } + } + try { + return { records: decodeRecords(raw.records, cipher) } + } catch { + return { records: [] } + } +} + +export function loadAgentSessionRecordStoreState( + path: string, + cipher: AgentSessionRecordCipher +): AgentSessionRecordStoreDurableState { + if (!existsSync(path)) { + return { records: [] } + } + try { + hardenExistingSecureFile(path) + return decodeAgentSessionRecordStore(JSON.parse(readFileSync(path, 'utf-8')), cipher) + } catch { + // A corrupt store must never block boot; start empty and let live sessions + // rebind on their next hook. + return { records: [] } + } +} + +export function writeAgentSessionRecordStoreState( + path: string, + state: AgentSessionRecordStoreDurableState, + cipher: AgentSessionRecordCipher +): void { + writeSecureJsonFile(path, encodeAgentSessionRecordStore(state, cipher)) +} + +/** Boot-time wiring: rehydrate durable records, then attach the write-back sink so + * every later bind/ingest/forget is persisted. Called once from main-process + * startup after the user data dir is stable. */ +export function initHostAgentSessionRecordStorePersistence(userDataPath: string): void { + const path = agentSessionRecordStorePath(userDataPath) + const cipher = electronSafeStorageCipher() + const state = loadAgentSessionRecordStoreState(path, cipher) + const store = getHostAgentSessionRecordStore() + store.rebuildRecordsFrom(state.records) + store.setDurablePersistence((next) => { + try { + writeAgentSessionRecordStoreState(path, next, cipher) + } catch { + // A failed persist must not break an in-flight bind; the in-memory store + // stays authoritative and the next mutation retries the write. + } + }) +} diff --git a/src/main/agent-launch/agent-session-record-store-vault.test.ts b/src/main/agent-launch/agent-session-record-store-vault.test.ts new file mode 100644 index 00000000000..75cb522d6ad --- /dev/null +++ b/src/main/agent-launch/agent-session-record-store-vault.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { + AgentSessionRecordStore, + type HostSessionLaunchRecord, + type StagedLaunchRegistration +} from './agent-session-record-store' + +const CUSTOM_CODEX_ID = 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as const + +function snapshot(overrides: Partial = {}): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: CUSTOM_CODEX_ID, + baseAgent: 'codex', + displayLabel: 'Original Codex', + mode: 'custom', + argv: ['codex', '--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + }, + ...overrides + } +} + +function record(overrides: Partial = {}): HostSessionLaunchRecord { + return { + worktreeId: 'wt-source', + requestedAgent: CUSTOM_CODEX_ID, + baseAgent: 'codex', + providerSession: { + key: 'session_id', + id: 'provider-session', + transcriptPath: '/home/me/.codex/sessions/transcript.jsonl' + }, + launchSnapshot: snapshot(), + registeredAt: 1, + updatedAt: 1, + ...overrides + } +} + +function resolve( + store: AgentSessionRecordStore, + overrides: Partial[0]> = {} +) { + return store.resolveVaultSnapshotOwner({ + baseAgent: 'codex', + scannedProviderSessionId: 'provider-session', + scannedTranscriptPath: '/home/me/.codex/sessions/transcript.jsonl', + targetExecutionHostId: 'local', + targetPlatform: 'linux', + preferredWorktreeId: 'wt-destination', + ...overrides + }) +} + +describe('AgentSessionRecordStore Vault correlation', () => { + it('uses a strong transcript match even when scanned and hook ids differ', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([record()]) + expect(resolve(store, { scannedProviderSessionId: 'scanner-id' })).toEqual({ + kind: 'found', + sessionKey: { + worktreeId: 'wt-source', + baseAgent: 'codex', + providerSessionId: 'provider-session' + } + }) + }) + + it('excludes a repeated provider id with a known different transcript', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([record()]) + expect( + resolve(store, { scannedTranscriptPath: '/home/me/.codex/sessions/other.jsonl' }) + ).toEqual({ kind: 'missing' }) + }) + + it('prefers the destination worktree and otherwise refuses ambiguous owners', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([ + record({ + worktreeId: 'wt-destination', + providerSession: { key: 'session_id', id: 'provider-session' } + }), + record({ + worktreeId: 'wt-other', + providerSession: { key: 'session_id', id: 'provider-session' } + }) + ]) + expect(resolve(store, { scannedTranscriptPath: null })).toMatchObject({ + kind: 'found', + sessionKey: { worktreeId: 'wt-destination' } + }) + expect(resolve(store, { scannedTranscriptPath: null, preferredWorktreeId: 'wt-none' })).toEqual( + { kind: 'ambiguous' } + ) + }) + + it('accepts a sole cross-worktree owner', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([ + record({ providerSession: { key: 'session_id', id: 'provider-session' } }) + ]) + expect(resolve(store, { scannedTranscriptPath: null })).toMatchObject({ + kind: 'found', + sessionKey: { worktreeId: 'wt-source' } + }) + }) + + it('matches a WSL UNC scan path to the hook-reported POSIX transcript', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([ + record({ + launchSnapshot: snapshot({ + target: { + platform: 'linux', + execution: 'wsl', + shell: 'posix', + isRemote: false, + executionHostId: 'wsl:Ubuntu' + } + }) + }) + ]) + expect( + resolve(store, { + scannedProviderSessionId: 'different-scanner-id', + scannedTranscriptPath: '\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions\\transcript.jsonl', + targetExecutionHostId: 'wsl:Ubuntu' + }) + ).toMatchObject({ kind: 'found' }) + expect( + resolve(store, { + scannedTranscriptPath: '\\\\wsl$\\Debian\\home\\me\\.codex\\sessions\\transcript.jsonl', + targetExecutionHostId: 'wsl:Ubuntu' + }) + ).toEqual({ kind: 'missing' }) + + expect( + store.resolveVaultSnapshotArguments({ + baseAgent: 'codex', + scannedProviderSessionId: 'different-scanner-id', + scannedTranscriptPath: '\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions\\transcript.jsonl', + scannedExecutionHostId: 'local' + }) + ).toEqual(['--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium']) + }) + + it('skips snapshotless, base-mismatched, and target-mismatched records', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([ + record({ launchSnapshot: undefined }), + record({ + worktreeId: 'wt-base-mismatch', + launchSnapshot: snapshot({ baseAgent: 'claude' }) + }), + record({ + worktreeId: 'wt-other-target', + launchSnapshot: snapshot({ + target: { ...snapshot().target, executionHostId: 'ssh:box', isRemote: true } + }) + }) + ]) + expect(resolve(store)).toEqual({ kind: 'missing' }) + }) + + it('skips corrupt snapshot and provider metadata while retaining no replay authority', () => { + const store = new AgentSessionRecordStore() + store.rebuildRecordsFrom([ + record({ + worktreeId: 'wt-bad-env', + launchSnapshot: snapshot({ agentEnv: [] as unknown as Record }) + }), + record({ + worktreeId: 'wt-bad-provider-key', + providerSession: { key: 'conversation_id', id: 'provider-session' } + }) + ]) + expect(resolve(store)).toEqual({ kind: 'missing' }) + }) + + it('updates indexes on overwrite, stale rollback, forget, and rehydrate', () => { + const store = new AgentSessionRecordStore() + const registration = (launchToken: string): Omit => ({ + worktreeId: 'wt-source', + requestedAgent: CUSTOM_CODEX_ID, + baseAgent: 'codex', + launchSnapshot: snapshot(), + launchToken, + paneKey: launchToken, + terminalId: launchToken + }) + store.register(registration('old-token')) + store.bindProviderSessionByToken('old-token', { + key: 'session_id', + id: 'provider-session', + transcriptPath: '/old.jsonl' + }) + store.register(registration('new-token')) + store.bindProviderSessionByToken('new-token', { + key: 'session_id', + id: 'provider-session', + transcriptPath: '/new.jsonl' + }) + store.rollbackByToken('old-token') + expect(resolve(store, { scannedTranscriptPath: '/new.jsonl' })).toMatchObject({ kind: 'found' }) + expect(resolve(store, { scannedTranscriptPath: '/old.jsonl' })).toEqual({ kind: 'missing' }) + + const durable = store.durableState() + const rebuilt = new AgentSessionRecordStore() + rebuilt.rebuildRecordsFrom(durable.records) + expect(resolve(rebuilt, { scannedTranscriptPath: '/new.jsonl' })).toMatchObject({ + kind: 'found' + }) + expect( + rebuilt.forget({ + worktreeId: 'wt-source', + baseAgent: 'codex', + providerSessionId: 'provider-session' + }) + ).toBe(true) + expect(resolve(rebuilt, { scannedTranscriptPath: '/new.jsonl' })).toEqual({ kind: 'missing' }) + }) +}) diff --git a/src/main/agent-launch/agent-session-record-store.test.ts b/src/main/agent-launch/agent-session-record-store.test.ts new file mode 100644 index 00000000000..d4e0c40a799 --- /dev/null +++ b/src/main/agent-launch/agent-session-record-store.test.ts @@ -0,0 +1,316 @@ +// U5: the host-private session record store's lifecycle invariants — spawn-time +// staging, provider-session bind (by launch token) → durable resume record, +// ownership-key resolution, incompatible/non-resumable bind rejection, spawn- +// failure rollback, dispose-keeps-record, and the one-time legacy handoff. +import { describe, expect, it } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { + getAgentSessionOwnershipKey, + type AgentProviderSessionMetadata, + type AgentSessionOwnershipKey, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import { + AgentSessionRecordStore, + type AgentSessionRecordStoreDurableState, + type StagedLaunchRegistration +} from './agent-session-record-store' + +function snapshot(overrides: Partial = {}): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + }, + ...overrides + } +} + +const SESSION: AgentProviderSessionMetadata = { key: 'session_id', id: 'sess-1' } + +function registration( + overrides: Partial> = {} +): Omit { + return { + paneKey: 'pane-a', + terminalId: 'term-a', + worktreeId: 'wt-1', + requestedAgent: 'claude', + baseAgent: 'claude', + launchSnapshot: snapshot(), + launchToken: 'token-a', + ...overrides + } +} + +const OWNERSHIP: AgentSessionOwnershipKey = { + worktreeId: 'wt-1', + baseAgent: 'claude', + providerSessionId: 'sess-1' +} + +/** Register the default pane and bind its provider session by token. */ +function registerAndBind(store: AgentSessionRecordStore): void { + store.register(registration()) + store.bindProviderSessionByToken('token-a', SESSION) +} + +describe('AgentSessionRecordStore lifecycle', () => { + it('a staged registration is not resumable until a provider session binds', () => { + const store = new AgentSessionRecordStore() + store.register(registration()) + expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull() + + const bound = store.bindProviderSessionByToken('token-a', SESSION) + expect(bound).not.toBeNull() + const record = store.resolveByOwnershipKey(OWNERSHIP) + expect(record?.launchSnapshot).toEqual(snapshot()) + expect(record?.launchToken).toBe('token-a') + expect(record?.requestedAgent).toBe('claude') + }) + + it('preserves the requested custom identity while keying ownership on the base', () => { + const store = new AgentSessionRecordStore() + store.register( + registration({ requestedAgent: 'custom-agent:claude:reviewer', baseAgent: 'claude' }) + ) + store.bindProviderSessionByToken('token-a', SESSION) + const record = store.resolveByOwnershipKey(OWNERSHIP) + expect(record?.requestedAgent).toBe('custom-agent:claude:reviewer') + expect(record?.baseAgent).toBe('claude') + }) + + it('binding an unknown launch token returns null and stores nothing', () => { + const store = new AgentSessionRecordStore() + expect(store.bindProviderSessionByToken('ghost-token', SESSION)).toBeNull() + expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull() + }) + + it('rejects an incompatible provider key type without rewriting the staged identity', () => { + const store = new AgentSessionRecordStore() + store.register(registration()) + // Claude keys on session_id; a conversation_id hook is incompatible evidence. + const bound = store.bindProviderSessionByToken('token-a', { key: 'conversation_id', id: 'x' }) + expect(bound).toBeNull() + expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull() + // A later compatible hook still binds the same staged registration. + expect(store.bindProviderSessionByToken('token-a', SESSION)).not.toBeNull() + }) + + it('never binds a non-resumable base', () => { + const store = new AgentSessionRecordStore() + store.register(registration({ baseAgent: 'cursor' })) + expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull() + }) + + it('a repeated hook for an already-bound launch is a no-op with no extra persist', () => { + let persistCalls = 0 + const store = new AgentSessionRecordStore() + store.setDurablePersistence(() => { + persistCalls += 1 + }) + store.register(registration()) + expect(store.bindProviderSessionByToken('token-a', SESSION)).not.toBeNull() + expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull() + expect(persistCalls).toBe(1) + }) + + it('rollback after bind removes the durable record so a failed spawn strands nothing', () => { + const store = new AgentSessionRecordStore() + registerAndBind(store) + store.rollbackByToken('token-a') + expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull() + }) + + it('rollback before bind drops the staged registration and its token index', () => { + const store = new AgentSessionRecordStore() + store.register(registration()) + store.rollbackByToken('token-a') + expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull() + }) + + it('dispose keeps the durable record so a slept session still resumes', () => { + const store = new AgentSessionRecordStore() + registerAndBind(store) + store.disposeStagingForPane('pane-a') + expect(store.resolveByOwnershipKey(OWNERSHIP)?.launchSnapshot).toEqual(snapshot()) + }) + + it('dispose clears an unbound pane staging so a late hook cannot bind a torn-down pane', () => { + const store = new AgentSessionRecordStore() + // Registered but never bound (spawn failed / pane closed before the hook). + store.register(registration()) + store.disposeStagingForPane('pane-a') + expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull() + }) + + it('two custom ids on one base/provider session resolve to one owner record', () => { + const store = new AgentSessionRecordStore() + store.register( + registration({ requestedAgent: 'custom-agent:claude:a', launchToken: 'token-a' }) + ) + store.bindProviderSessionByToken('token-a', SESSION) + store.register( + registration({ + paneKey: 'pane-b', + terminalId: 'term-b', + requestedAgent: 'custom-agent:claude:b', + launchToken: 'token-b' + }) + ) + store.bindProviderSessionByToken('token-b', SESSION) + // Same ownership key: the later bind overwrites; still one record. + expect(store.durableState().records).toHaveLength(1) + expect(store.resolveByOwnershipKey(OWNERSHIP)?.requestedAgent).toBe('custom-agent:claude:b') + }) + + it('a fork binds a NEW provider session into its own record and never mutates the source', () => { + const store = new AgentSessionRecordStore() + // Source session, bound to sess-1. + registerAndBind(store) + const source = store.resolveByOwnershipKey(OWNERSHIP) + // Fork: its own launch token + a COPY of the source snapshot, but the forked + // CLI reports a brand-new provider session id, so it keys a distinct record. + store.register( + registration({ + paneKey: 'pane-fork', + terminalId: 'term-fork', + requestedAgent: 'custom-agent:claude:fork', + launchToken: 'token-fork' + }) + ) + store.bindProviderSessionByToken('token-fork', { key: 'session_id', id: 'sess-2-fork' }) + // Source record is untouched (same identity, same token — no ownership claim). + expect(store.resolveByOwnershipKey(OWNERSHIP)).toEqual(source) + // The fork owns a separate record under its new provider session id. + const forkKey: AgentSessionOwnershipKey = { + worktreeId: 'wt-1', + baseAgent: 'claude', + providerSessionId: 'sess-2-fork' + } + expect(store.resolveByOwnershipKey(forkKey)?.requestedAgent).toBe('custom-agent:claude:fork') + expect(store.durableState().records).toHaveLength(2) + }) + + it('forget removes the durable record', () => { + const store = new AgentSessionRecordStore() + registerAndBind(store) + expect(store.forget(OWNERSHIP)).toBe(true) + expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull() + expect(store.forget(OWNERSHIP)).toBe(false) + }) +}) + +describe('AgentSessionRecordStore legacy handoff', () => { + const legacyConfig: SleepingAgentLaunchConfig = { + agentArgs: '--resume sess-1', + agentEnv: { FOO: 'bar' } + } + + it('ingests the legacy config once and keys it by ownership', () => { + const store = new AgentSessionRecordStore() + const record = store.ingestLegacyRecord({ + ownershipKey: OWNERSHIP, + requestedAgent: 'claude', + providerSession: SESSION, + legacyLaunchConfig: legacyConfig, + connectionId: 'ssh:box' + }) + expect(record.legacyLaunchConfig).toEqual(legacyConfig) + expect(record.legacyConnectionId).toBe('ssh:box') + expect(record.launchSnapshot).toBeUndefined() + expect(store.resolveByOwnershipKey(OWNERSHIP)?.legacyLaunchConfig).toEqual(legacyConfig) + }) + + it('never overwrites a host-owned record on a repeated handoff', () => { + const store = new AgentSessionRecordStore() + registerAndBind(store) + const returned = store.ingestLegacyRecord({ + ownershipKey: OWNERSHIP, + requestedAgent: 'claude', + providerSession: SESSION, + legacyLaunchConfig: legacyConfig, + connectionId: null + }) + // The v1-snapshot record wins; the legacy blob is discarded. + expect(returned.launchSnapshot).toEqual(snapshot()) + expect(returned.legacyLaunchConfig).toBeUndefined() + }) +}) + +describe('AgentSessionRecordStore durable persistence', () => { + it('routes bind/ingest/forget through the sink and rehydrates by ownership key', () => { + let persisted: AgentSessionRecordStoreDurableState = { records: [] } + const store = new AgentSessionRecordStore() + store.setDurablePersistence((state) => { + persisted = state + }) + registerAndBind(store) + expect(persisted.records).toHaveLength(1) + + const rebuilt = new AgentSessionRecordStore() + rebuilt.rebuildRecordsFrom(persisted.records) + expect(rebuilt.resolveByOwnershipKey(OWNERSHIP)?.launchToken).toBe('token-a') + }) + + it('register alone does not persist; only a bound record is durable', () => { + let calls = 0 + const store = new AgentSessionRecordStore() + store.setDurablePersistence(() => { + calls += 1 + }) + store.register(registration()) + expect(calls).toBe(0) + store.bindProviderSessionByToken('token-a', SESSION) + expect(calls).toBe(1) + }) + + it('rehydrate keys records on the base+session, not the persisted array order', () => { + const store = new AgentSessionRecordStore() + const other = getAgentSessionOwnershipKey({ + worktreeId: 'wt-2', + baseAgent: 'codex', + providerSessionId: 'sess-2' + }) + store.rebuildRecordsFrom([ + { + worktreeId: 'wt-1', + requestedAgent: 'claude', + baseAgent: 'claude', + providerSession: SESSION, + launchSnapshot: snapshot(), + registeredAt: 1, + updatedAt: 1 + }, + { + worktreeId: 'wt-2', + requestedAgent: 'codex', + baseAgent: 'codex', + providerSession: { key: 'session_id', id: 'sess-2' }, + launchSnapshot: snapshot({ baseAgent: 'codex', requestedAgent: 'codex' }), + registeredAt: 2, + updatedAt: 2 + } + ]) + expect(store.resolveByOwnershipKey(OWNERSHIP)?.baseAgent).toBe('claude') + expect( + store.resolveByOwnershipKey({ + worktreeId: 'wt-2', + baseAgent: 'codex', + providerSessionId: 'sess-2' + })?.baseAgent + ).toBe('codex') + expect(other).toContain('codex') + }) +}) diff --git a/src/main/agent-launch/agent-session-record-store.ts b/src/main/agent-launch/agent-session-record-store.ts new file mode 100644 index 00000000000..0ed6b7dbf14 --- /dev/null +++ b/src/main/agent-launch/agent-session-record-store.ts @@ -0,0 +1,347 @@ +// Host-private launch-attribution + resume record store (U5). Holds the fields a +// client record must never carry (ruling D1): the immutable `launchSnapshot`, the +// opaque one-release `legacyLaunchConfig`, and the admission launch token. The +// runtime/mobile/paired session DTO exposes only requested/base identity, provider +// metadata, and notice/failure state; those live in the renderer store, not here. +// +// Two lifecycle stages, per plan §577/§579: +// 1. Registration at spawn time by stable pane + terminal id, BEFORE the PTY can +// emit output/hooks. The provider session is not known yet, so the record is +// staged and cannot be resumed. Rolled back on spawn failure. +// 2. Provider-session bind once a hook reports the session id: the staged record +// is promoted to a durable record keyed by the {worktreeId, baseAgent, +// providerSessionId} ownership key. A resume/fork request names that key and +// the host loads the private record here. The record survives pane dispose so +// a slept session still resumes; it is dropped only when explicitly forgotten. +// +// The store is a pure container: legacy-config validation and Agent Teams env +// stripping live in the ingestion/adapter layer, never here. + +import type { TuiAgent, BuiltInTuiAgent } from '../../shared/types' +import type { + AgentLaunchExecutionHostId, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' +import { + getAgentSessionOwnershipKey, + isResumableTuiAgent, + normalizeAgentProviderSession, + providerSessionKeyForResumableBase, + type AgentProviderSessionMetadata, + type AgentSessionOwnershipKey, + type ResumableTuiAgent, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import { + AgentSessionVaultSnapshotIndex, + type VaultSnapshotOwnerResolution +} from './agent-session-vault-snapshot-index' + +export type { VaultSnapshotOwnerResolution } from './agent-session-vault-snapshot-index' + +/** A durable resume record, keyed by ownership key once a provider session binds. + * `launchSnapshot` (v1 replay authority) and `legacyLaunchConfig` (opaque + * one-release replay) are mutually exclusive in practice; a record with neither + * resolves current settings at resume (the snapshotless migration window). */ +export type HostSessionLaunchRecord = { + worktreeId: string + requestedAgent: TuiAgent + baseAgent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + launchSnapshot?: AgentLaunchSnapshot + legacyLaunchConfig?: SleepingAgentLaunchConfig + /** Recorded execution owner of a legacy record's sleeping pane. Opaque legacy + * replay re-checks it against the current spawn's owner on every resume (plan + * §573); v1-snapshot records carry provenance in the snapshot target instead. */ + legacyConnectionId?: string | null + launchToken?: string + registeredAt: number + updatedAt: number +} + +/** A spawn-time registration before any provider session is known. Keyed by + * launch token; rolled back on spawn failure and promoted to a durable record + * when the session binds. `baseAgent` may be non-resumable: such launches never + * bind a session. `paneKey`/`terminalId` are optional attribution metadata (the + * token drives bind/rollback); `paneKey` lets a pane teardown drop unbound + * staging, and surfaces without a stable pane key simply omit it. */ +export type StagedLaunchRegistration = { + paneKey?: string + terminalId?: string + worktreeId: string + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent + launchSnapshot: AgentLaunchSnapshot + launchToken: string + registeredAt: number +} + +/** The one-time legacy handoff: the renderer surrenders a pre-upgrade launch + * config on first resume over trusted desktop IPC. The host reconstructs the + * record from the ownership key it already holds and owns the config thereafter. */ +export type LegacySessionRecordHandoff = { + ownershipKey: AgentSessionOwnershipKey + requestedAgent: TuiAgent + providerSession: AgentProviderSessionMetadata + legacyLaunchConfig: SleepingAgentLaunchConfig + /** Recorded execution owner of the sleeping pane, kept for later provenance + * re-checks once the host owns the config. */ + connectionId: string | null +} + +/** The durable half snapshotted for the host-private sink: the ownership-keyed + * records only. Staging is in-flight and rebuilt from live terminals on restart + * via reconciliation, so it is never persisted. */ +export type AgentSessionRecordStoreDurableState = { + records: readonly HostSessionLaunchRecord[] +} + +export class AgentSessionRecordStore { + // Spawn-time registrations, keyed by launch token (the stable handle both the + // spawn caller and the hook carry), before a session binds. + private readonly staging = new Map() + // Durable resume records, keyed by ownership key. + private readonly records = new Map() + private readonly vaultIndex = new AgentSessionVaultSnapshotIndex() + // launchToken -> ownership key of the record it bound to, so a spawn-failure + // rollback of an already-bound launch removes its durable record too. + private readonly ownershipByToken = new Map() + private readonly now: () => number + private onDurableMutation: ((state: AgentSessionRecordStoreDurableState) => void) | null = null + + constructor(deps?: { now?: () => number }) { + this.now = deps?.now ?? (() => Date.now()) + } + + /** Attach (or replace) the durable sink. Not called during rehydrate, so the + * load path never writes back the state it just read. */ + setDurablePersistence(sink: (state: AgentSessionRecordStoreDurableState) => void): void { + this.onDurableMutation = sink + } + + durableState(): AgentSessionRecordStoreDurableState { + return { records: [...this.records.values()] } + } + + private persistDurable(): void { + this.onDurableMutation?.(this.durableState()) + } + + private deleteDurableRecord(ownershipKey: string): boolean { + const record = this.records.get(ownershipKey) + if (!record) { + return false + } + this.vaultIndex.remove(ownershipKey, record) + this.records.delete(ownershipKey) + if (record.launchToken && this.ownershipByToken.get(record.launchToken) === ownershipKey) { + this.ownershipByToken.delete(record.launchToken) + } + return true + } + + /** §577 spawn-time registration, keyed by launch token. Held in staging; not + * resumable until a hook binds its provider session. */ + register(registration: Omit): void { + this.staging.set(registration.launchToken, { ...registration, registeredAt: this.now() }) + } + + /** Drop a staged registration on spawn failure. If it was already promoted, the + * bound durable record is removed too so a failed spawn strands nothing. */ + rollbackByToken(launchToken: string): void { + this.staging.delete(launchToken) + const ownershipKey = this.ownershipByToken.get(launchToken) + if (ownershipKey) { + this.ownershipByToken.delete(launchToken) + const record = this.records.get(ownershipKey) + if (record?.launchToken === launchToken && this.deleteDurableRecord(ownershipKey)) { + this.persistDurable() + } + } + } + + /** Drop the in-flight staging for a pane when its PTY ends. The durable record + * (if the session bound) is intentionally KEPT so a slept session resumes; only + * the unbound staging handle is cleared. Staging is small (bounded by concurrent + * unbound launches), so a scan is cheaper than a second index. */ + disposeStagingForPane(paneKey: string): void { + for (const [token, staged] of this.staging) { + if (staged.paneKey === paneKey) { + this.staging.delete(token) + } + } + } + + /** Promote a staged registration to a durable resume record once a hook reports + * the provider session for its launch token. The record's OWN base agent (host + * attribution) — never the hook's provider evidence — drives the ownership key, + * and the hook's session is accepted only when its key type matches that base. + * An incompatible provider type is rejected (returns null) without rewriting the + * staged identity; a non-resumable base can never bind. A successful bind + * consumes its staging entry, so a repeated hook for the same launch is a null + * no-op (an incompatible attempt keeps staging so a later compatible hook wins). */ + bindProviderSessionByToken( + launchToken: string, + providerSession: AgentProviderSessionMetadata + ): HostSessionLaunchRecord | null { + const staged = this.staging.get(launchToken) + if (!staged) { + return null + } + if ( + !isResumableTuiAgent(staged.baseAgent) || + providerSession.key !== providerSessionKeyForResumableBase(staged.baseAgent) + ) { + return null + } + const ownershipKey = getAgentSessionOwnershipKey({ + worktreeId: staged.worktreeId, + baseAgent: staged.baseAgent, + providerSessionId: providerSession.id + }) + const record: HostSessionLaunchRecord = { + worktreeId: staged.worktreeId, + requestedAgent: staged.requestedAgent, + baseAgent: staged.baseAgent, + providerSession, + launchSnapshot: staged.launchSnapshot, + launchToken: staged.launchToken, + registeredAt: staged.registeredAt, + updatedAt: this.now() + } + const replaced = this.records.get(ownershipKey) + if (replaced) { + this.vaultIndex.remove(ownershipKey, replaced) + if (replaced.launchToken && replaced.launchToken !== launchToken) { + this.ownershipByToken.delete(replaced.launchToken) + } + } + this.records.set(ownershipKey, record) + this.vaultIndex.add(ownershipKey, record) + this.ownershipByToken.set(launchToken, ownershipKey) + // Consume the staging entry so repeated hook events for the same launch are a + // cheap no-op (no duplicate durable write); rollback still finds the bound + // record via the ownership index. + this.staging.delete(launchToken) + this.persistDurable() + return record + } + + /** Resolve the private record a resume/fork request names. */ + resolveByOwnershipKey(key: AgentSessionOwnershipKey): HostSessionLaunchRecord | null { + return this.records.get(getAgentSessionOwnershipKey(key)) ?? null + } + + /** Correlate a freshly scanned Vault row to one eligible v1 snapshot owner. */ + resolveVaultSnapshotOwner(args: { + baseAgent: ResumableTuiAgent + scannedProviderSessionId: string + scannedTranscriptPath?: string | null + targetExecutionHostId: AgentLaunchExecutionHostId + targetPlatform: NodeJS.Platform + preferredWorktreeId?: string | null + }): VaultSnapshotOwnerResolution { + return this.vaultIndex.resolve(args, this.records) + } + + /** Return only the captured non-executable argv after conservative Vault + * correlation. This is the narrow disclosure used by expanded details. */ + resolveVaultSnapshotArguments(args: { + baseAgent: ResumableTuiAgent + scannedProviderSessionId: string + scannedTranscriptPath?: string | null + scannedExecutionHostId: string + }): readonly string[] | null { + const owner = this.vaultIndex.resolveForDiscoveredHost(args, this.records) + if (owner.kind !== 'found') { + return null + } + const record = this.resolveByOwnershipKey(owner.sessionKey) + return record?.launchSnapshot ? record.launchSnapshot.argv.slice(1) : null + } + + /** Requested identities of every durable resume record, for the tombstone + * reference index's `session` owner (plan §266). Each bound resumable session + * registers here, so a custom id still named here keeps its tombstone retained + * until the session is forgotten. */ + referencedRequestedAgents(): TuiAgent[] { + return [...this.records.values()].map((record) => record.requestedAgent) + } + + /** Count durable resume records whose base harness is `base`, for §973 + * base-disable impact. Records are keyed by their host-attributed base, so a + * derivative launch (baseAgent === base) is counted alongside a direct base + * launch — every session that will block when the harness is disabled. */ + countRecordsByBase(base: BuiltInTuiAgent): number { + let count = 0 + for (const record of this.records.values()) { + if (record.baseAgent === base) { + count += 1 + } + } + return count + } + + /** Accept the one-time legacy launch config the renderer surrenders on first + * resume. Ignored when the host already owns a record for the key (already + * handed over): "renderer hands it over once; host owns it thereafter". */ + ingestLegacyRecord(handoff: LegacySessionRecordHandoff): HostSessionLaunchRecord { + const ownershipKey = getAgentSessionOwnershipKey(handoff.ownershipKey) + const existing = this.records.get(ownershipKey) + if (existing) { + return existing + } + const now = this.now() + const record: HostSessionLaunchRecord = { + worktreeId: handoff.ownershipKey.worktreeId, + requestedAgent: handoff.requestedAgent, + baseAgent: handoff.ownershipKey.baseAgent, + providerSession: handoff.providerSession, + legacyLaunchConfig: handoff.legacyLaunchConfig, + legacyConnectionId: handoff.connectionId, + registeredAt: now, + updatedAt: now + } + this.records.set(ownershipKey, record) + this.vaultIndex.add(ownershipKey, record) + this.persistDurable() + return record + } + + /** Owner-authorized forget: drop the durable record entirely. */ + forget(key: AgentSessionOwnershipKey): boolean { + const deleted = this.deleteDurableRecord(getAgentSessionOwnershipKey(key)) + if (deleted) { + this.persistDurable() + } + return deleted + } + + /** Rehydrate durable records at startup. Not routed through the sink. */ + rebuildRecordsFrom(records: Iterable): void { + this.records.clear() + this.vaultIndex.clear() + this.ownershipByToken.clear() + for (const record of records) { + const providerSession = normalizeAgentProviderSession(record?.providerSession) + if ( + typeof record?.worktreeId !== 'string' || + !record.worktreeId || + !isResumableTuiAgent(record.baseAgent) || + !providerSession + ) { + continue + } + const ownershipKey = getAgentSessionOwnershipKey({ + worktreeId: record.worktreeId, + baseAgent: record.baseAgent, + providerSessionId: providerSession.id + }) + this.records.set(ownershipKey, record) + this.vaultIndex.add(ownershipKey, record) + if (record.launchToken) { + this.ownershipByToken.set(record.launchToken, ownershipKey) + } + } + } +} diff --git a/src/main/agent-launch/agent-session-transcript-identity.test.ts b/src/main/agent-launch/agent-session-transcript-identity.test.ts new file mode 100644 index 00000000000..4cc75c571e4 --- /dev/null +++ b/src/main/agent-launch/agent-session-transcript-identity.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { canonicalAgentSessionTranscriptIdentity } from './agent-session-transcript-identity' + +describe('canonicalAgentSessionTranscriptIdentity', () => { + it('normalizes POSIX and Windows transcript identities', () => { + expect( + canonicalAgentSessionTranscriptIdentity({ + transcriptPath: '/home/me/a/../session.jsonl', + targetExecutionHostId: 'local', + targetPlatform: 'linux' + }) + ).toBe('posix:/home/me/session.jsonl') + expect( + canonicalAgentSessionTranscriptIdentity({ + transcriptPath: 'C:\\Users\\ME\\session.jsonl', + targetExecutionHostId: 'local', + targetPlatform: 'win32' + }) + ).toBe('windows:c:/users/me/session.jsonl') + }) + + it('maps WSL UNC paths to POSIX only for the target distro', () => { + expect( + canonicalAgentSessionTranscriptIdentity({ + transcriptPath: '\\\\wsl$\\Ubuntu\\home\\me\\session.jsonl', + targetExecutionHostId: 'wsl:Ubuntu', + targetPlatform: 'linux' + }) + ).toBe('posix:/home/me/session.jsonl') + expect( + canonicalAgentSessionTranscriptIdentity({ + transcriptPath: '\\\\wsl.localhost\\Debian\\home\\me\\session.jsonl', + targetExecutionHostId: 'wsl:Ubuntu', + targetPlatform: 'linux' + }) + ).toBeNull() + }) + + it('compares UNC distro names with decoded execution-host ids', () => { + expect( + canonicalAgentSessionTranscriptIdentity({ + transcriptPath: '\\\\wsl$\\Ubuntu 22.04\\home\\me\\session.jsonl', + targetExecutionHostId: 'wsl:Ubuntu%2022.04', + targetPlatform: 'linux' + }) + ).toBe('posix:/home/me/session.jsonl') + }) + + it('drops relative and malformed path evidence', () => { + expect( + canonicalAgentSessionTranscriptIdentity({ + transcriptPath: 'relative/session.jsonl', + targetExecutionHostId: 'local', + targetPlatform: 'linux' + }) + ).toBeNull() + }) +}) diff --git a/src/main/agent-launch/agent-session-transcript-identity.ts b/src/main/agent-launch/agent-session-transcript-identity.ts new file mode 100644 index 00000000000..1e6f65e2ce1 --- /dev/null +++ b/src/main/agent-launch/agent-session-transcript-identity.ts @@ -0,0 +1,71 @@ +import { posix, win32 } from 'node:path' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import { parseWslUncPath } from '../../shared/wsl-paths' + +function wslDistroFromExecutionHostId( + targetExecutionHostId: AgentLaunchExecutionHostId +): string | null { + if (!targetExecutionHostId.startsWith('wsl:')) { + return null + } + try { + return decodeURIComponent(targetExecutionHostId.slice('wsl:'.length)) || null + } catch { + return null + } +} + +export function transcriptPathConflictsWithWslTarget( + transcriptPath: string, + targetExecutionHostId: AgentLaunchExecutionHostId +): boolean { + if (!targetExecutionHostId.startsWith('wsl:')) { + return false + } + const targetDistro = wslDistroFromExecutionHostId(targetExecutionHostId) + const unc = parseWslUncPath(transcriptPath.trim()) + return Boolean(unc && (!targetDistro || unc.distro.toLowerCase() !== targetDistro.toLowerCase())) +} + +function usableAbsolutePath(value: string, platform: NodeJS.Platform): boolean { + return ( + value.length > 0 && + !value.includes('\0') && + (platform === 'win32' ? win32.isAbsolute(value) : posix.isAbsolute(value)) + ) +} + +/** Canonical host-private transcript identity used only by correlation indexes. */ +export function canonicalAgentSessionTranscriptIdentity(args: { + transcriptPath: string + targetExecutionHostId: AgentLaunchExecutionHostId + targetPlatform: NodeJS.Platform +}): string | null { + const raw = args.transcriptPath.trim() + if (!raw) { + return null + } + + if (args.targetExecutionHostId.startsWith('wsl:')) { + const targetDistro = wslDistroFromExecutionHostId(args.targetExecutionHostId) + if (!targetDistro) { + return null + } + const unc = parseWslUncPath(raw) + if (unc && unc.distro.toLowerCase() !== targetDistro.toLowerCase()) { + return null + } + const linuxPath = unc?.linuxPath ?? raw + if (!usableAbsolutePath(linuxPath, 'linux')) { + return null + } + return `posix:${posix.normalize(linuxPath)}` + } + + if (!usableAbsolutePath(raw, args.targetPlatform)) { + return null + } + return args.targetPlatform === 'win32' + ? `windows:${win32.normalize(raw).replace(/\\/g, '/').toLowerCase()}` + : `posix:${posix.normalize(raw)}` +} diff --git a/src/main/agent-launch/agent-session-vault-snapshot-index.ts b/src/main/agent-launch/agent-session-vault-snapshot-index.ts new file mode 100644 index 00000000000..25f55aaa356 --- /dev/null +++ b/src/main/agent-launch/agent-session-vault-snapshot-index.ts @@ -0,0 +1,317 @@ +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import { + isResumableTuiAgent, + normalizeAgentProviderSession, + providerSessionKeyForResumableBase, + type AgentSessionOwnershipKey, + type ResumableTuiAgent +} from '../../shared/agent-session-resume' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { + canonicalAgentSessionTranscriptIdentity, + transcriptPathConflictsWithWslTarget +} from './agent-session-transcript-identity' +import type { HostSessionLaunchRecord } from './agent-session-record-store' +import { + AgentSessionVaultTargetIndex, + vaultSessionKeyForRecord, + type VaultSnapshotScanIdentity +} from './agent-session-vault-target-index' + +export type VaultSnapshotOwnerResolution = + | { kind: 'found'; sessionKey: AgentSessionOwnershipKey } + | { kind: 'missing' } + | { kind: 'ambiguous' } + +const NODE_PLATFORMS = new Set([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +]) +const SNAPSHOT_MODES = new Set(['built-in', 'custom', 'safe-fallback']) +const CAPTURED_ENV_POLICIES = new Set(['full', 'withheld', 'none']) +const STARTUP_SHELLS = new Set(['posix', 'powershell', 'cmd']) + +function isStringRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === 'string') + ) +} + +function isExecutionHostId(value: unknown): value is AgentLaunchExecutionHostId { + if (value === 'local') { + return true + } + if (typeof value !== 'string' || !/^(?:ssh|runtime|wsl):.+$/.test(value)) { + return false + } + if (!value.startsWith('wsl:')) { + return true + } + try { + return decodeURIComponent(value.slice('wsl:'.length)).length > 0 + } catch { + return false + } +} + +function isSnapshotIndexEligible(record: HostSessionLaunchRecord): boolean { + const snapshot = record.launchSnapshot + const providerSession = normalizeAgentProviderSession(record.providerSession) + return Boolean( + typeof record.worktreeId === 'string' && + record.worktreeId.length > 0 && + isTuiAgent(record.requestedAgent) && + isResumableTuiAgent(record.baseAgent) && + providerSession && + providerSession.key === providerSessionKeyForResumableBase(record.baseAgent) && + snapshot && + snapshot.version === 1 && + isTuiAgent(snapshot.requestedAgent) && + snapshot.baseAgent === record.baseAgent && + typeof snapshot.displayLabel === 'string' && + SNAPSHOT_MODES.has(snapshot.mode) && + Array.isArray(snapshot.argv) && + snapshot.argv.length > 0 && + snapshot.argv.every((value) => typeof value === 'string') && + snapshot.argv[0].length > 0 && + isStringRecord(snapshot.agentEnv) && + CAPTURED_ENV_POLICIES.has(snapshot.capturedEnvPolicy) && + snapshot.target && + isExecutionHostId(snapshot.target.executionHostId) && + typeof snapshot.target.executionHostId === 'string' && + NODE_PLATFORMS.has(snapshot.target.platform) && + (snapshot.target.execution === 'native' || snapshot.target.execution === 'wsl') && + STARTUP_SHELLS.has(snapshot.target.shell) && + typeof snapshot.target.isRemote === 'boolean' + ) +} + +function providerIndexKey( + targetExecutionHostId: AgentLaunchExecutionHostId, + baseAgent: ResumableTuiAgent, + providerSessionId: string +): string { + return `${targetExecutionHostId}\0${baseAgent}\0${providerSessionId}` +} + +function transcriptIndexKey( + targetExecutionHostId: AgentLaunchExecutionHostId, + baseAgent: ResumableTuiAgent, + transcriptIdentity: string +): string { + return `${targetExecutionHostId}\0${baseAgent}\0${transcriptIdentity}` +} + +/** Derived, in-memory-only indexes for Vault-to-private-record correlation. */ +export class AgentSessionVaultSnapshotIndex { + private readonly ownershipByProvider = new Map>() + private readonly ownershipByTranscript = new Map>() + private readonly targetIndex = new AgentSessionVaultTargetIndex() + + clear(): void { + this.ownershipByProvider.clear() + this.ownershipByTranscript.clear() + this.targetIndex.clear() + } + + add(ownershipKey: string, record: HostSessionLaunchRecord): void { + if (!isSnapshotIndexEligible(record) || !record.launchSnapshot) { + return + } + const target = record.launchSnapshot.target + this.targetIndex.add(record.baseAgent, target) + this.addIndexValue( + this.ownershipByProvider, + providerIndexKey(target.executionHostId, record.baseAgent, record.providerSession.id), + ownershipKey + ) + const transcriptIdentity = this.recordTranscriptIdentity(record) + if (transcriptIdentity) { + this.addIndexValue( + this.ownershipByTranscript, + transcriptIndexKey(target.executionHostId, record.baseAgent, transcriptIdentity), + ownershipKey + ) + } + } + + remove(ownershipKey: string, record: HostSessionLaunchRecord): void { + if (!isSnapshotIndexEligible(record) || !record.launchSnapshot) { + return + } + const target = record.launchSnapshot.target + this.targetIndex.remove(record.baseAgent, target) + this.deleteIndexValue( + this.ownershipByProvider, + providerIndexKey(target.executionHostId, record.baseAgent, record.providerSession.id), + ownershipKey + ) + const transcriptIdentity = this.recordTranscriptIdentity(record) + if (transcriptIdentity) { + this.deleteIndexValue( + this.ownershipByTranscript, + transcriptIndexKey(target.executionHostId, record.baseAgent, transcriptIdentity), + ownershipKey + ) + } + } + + resolve( + args: { + baseAgent: ResumableTuiAgent + scannedProviderSessionId: string + scannedTranscriptPath?: string | null + targetExecutionHostId: AgentLaunchExecutionHostId + targetPlatform: NodeJS.Platform + preferredWorktreeId?: string | null + }, + records: ReadonlyMap + ): VaultSnapshotOwnerResolution { + if ( + args.scannedTranscriptPath && + transcriptPathConflictsWithWslTarget(args.scannedTranscriptPath, args.targetExecutionHostId) + ) { + return { kind: 'missing' } + } + const transcriptIdentity = args.scannedTranscriptPath + ? canonicalAgentSessionTranscriptIdentity({ + transcriptPath: args.scannedTranscriptPath, + targetExecutionHostId: args.targetExecutionHostId, + targetPlatform: args.targetPlatform + }) + : null + const pathCandidates = transcriptIdentity + ? this.ownershipByTranscript.get( + transcriptIndexKey(args.targetExecutionHostId, args.baseAgent, transcriptIdentity) + ) + : undefined + if (pathCandidates && pathCandidates.size > 0) { + return this.selectOwner(pathCandidates, args.preferredWorktreeId, records) + } + + const idCandidates = this.ownershipByProvider.get( + providerIndexKey(args.targetExecutionHostId, args.baseAgent, args.scannedProviderSessionId) + ) + if (!idCandidates || idCandidates.size === 0) { + return { kind: 'missing' } + } + const survivors = new Set() + for (const ownershipKey of idCandidates) { + const record = records.get(ownershipKey) + if (!record?.launchSnapshot || !isSnapshotIndexEligible(record)) { + continue + } + const recordIdentity = this.recordTranscriptIdentity(record) + // A known different transcript proves a repeated provider id is not this row. + if (transcriptIdentity && recordIdentity && transcriptIdentity !== recordIdentity) { + continue + } + survivors.add(ownershipKey) + } + return this.selectOwner(survivors, args.preferredWorktreeId, records) + } + + /** Resolve display-only snapshot data without accepting a client-authored + * target. A local Vault scan may represent either native or WSL storage. */ + resolveForDiscoveredHost( + args: VaultSnapshotScanIdentity, + records: ReadonlyMap + ): VaultSnapshotOwnerResolution { + const targets = this.targetIndex.matching(args.baseAgent, args.scannedExecutionHostId) + if (targets.length === 0) { + return { kind: 'missing' } + } + const found = new Map() + for (const target of targets) { + const resolution = this.resolve( + { + baseAgent: args.baseAgent, + scannedProviderSessionId: args.scannedProviderSessionId, + scannedTranscriptPath: args.scannedTranscriptPath, + targetExecutionHostId: target.executionHostId, + targetPlatform: target.platform + }, + records + ) + if (resolution.kind === 'ambiguous') { + return resolution + } + if (resolution.kind === 'found') { + found.set(JSON.stringify(resolution.sessionKey), resolution.sessionKey) + } + } + if (found.size === 1) { + return { kind: 'found', sessionKey: [...found.values()][0] } + } + return found.size === 0 ? { kind: 'missing' } : { kind: 'ambiguous' } + } + + private recordTranscriptIdentity(record: HostSessionLaunchRecord): string | null { + const snapshot = record.launchSnapshot + const transcriptPath = record.providerSession.transcriptPath + return snapshot && transcriptPath + ? canonicalAgentSessionTranscriptIdentity({ + transcriptPath, + targetExecutionHostId: snapshot.target.executionHostId, + targetPlatform: snapshot.target.platform + }) + : null + } + + private selectOwner( + ownershipKeys: ReadonlySet, + preferredWorktreeId: string | null | undefined, + records: ReadonlyMap + ): VaultSnapshotOwnerResolution { + const candidates = [...ownershipKeys].flatMap((ownershipKey) => { + const record = records.get(ownershipKey) + return record && isSnapshotIndexEligible(record) ? [record] : [] + }) + if (preferredWorktreeId) { + const preferred = candidates.filter((record) => record.worktreeId === preferredWorktreeId) + if (preferred.length === 1) { + return { kind: 'found', sessionKey: vaultSessionKeyForRecord(preferred[0]) } + } + if (preferred.length > 1) { + return { kind: 'ambiguous' } + } + } + if (candidates.length === 1) { + return { kind: 'found', sessionKey: vaultSessionKeyForRecord(candidates[0]) } + } + return candidates.length === 0 ? { kind: 'missing' } : { kind: 'ambiguous' } + } + + private addIndexValue(index: Map>, key: string, ownershipKey: string): void { + const values = index.get(key) ?? new Set() + values.add(ownershipKey) + index.set(key, values) + } + + private deleteIndexValue( + index: Map>, + key: string, + ownershipKey: string + ): void { + const values = index.get(key) + if (!values) { + return + } + values.delete(ownershipKey) + if (values.size === 0) { + index.delete(key) + } + } +} diff --git a/src/main/agent-launch/agent-session-vault-target-index.ts b/src/main/agent-launch/agent-session-vault-target-index.ts new file mode 100644 index 00000000000..175c574f302 --- /dev/null +++ b/src/main/agent-launch/agent-session-vault-target-index.ts @@ -0,0 +1,88 @@ +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import type { AgentSessionOwnershipKey, ResumableTuiAgent } from '../../shared/agent-session-resume' + +export type IndexedSnapshotTarget = { + executionHostId: AgentLaunchExecutionHostId + platform: NodeJS.Platform +} + +export type VaultSnapshotScanIdentity = { + baseAgent: ResumableTuiAgent + scannedProviderSessionId: string + scannedTranscriptPath?: string | null + scannedExecutionHostId: string +} + +export function vaultSessionKeyForRecord(record: { + worktreeId: string + baseAgent: ResumableTuiAgent + providerSession: { id: string } +}): AgentSessionOwnershipKey { + return { + worktreeId: record.worktreeId, + baseAgent: record.baseAgent, + providerSessionId: record.providerSession.id + } +} + +/** Reference-counted target inventory for the private Vault correlation index. */ +export class AgentSessionVaultTargetIndex { + private readonly targetsByBase = new Map>() + + clear(): void { + this.targetsByBase.clear() + } + + add(baseAgent: ResumableTuiAgent, target: IndexedSnapshotTarget): void { + const targets = this.targetsByBase.get(baseAgent) ?? new Map() + const key = targetKey(target) + targets.set(key, (targets.get(key) ?? 0) + 1) + this.targetsByBase.set(baseAgent, targets) + } + + remove(baseAgent: ResumableTuiAgent, target: IndexedSnapshotTarget): void { + const targets = this.targetsByBase.get(baseAgent) + if (!targets) { + return + } + const key = targetKey(target) + const count = targets.get(key) ?? 0 + if (count <= 1) { + targets.delete(key) + } else { + targets.set(key, count - 1) + } + if (targets.size === 0) { + this.targetsByBase.delete(baseAgent) + } + } + + matching(baseAgent: ResumableTuiAgent, scannedExecutionHostId: string): IndexedSnapshotTarget[] { + const targets = this.targetsByBase.get(baseAgent) + if (!targets) { + return [] + } + return [...targets.keys()] + .map((key) => JSON.parse(key) as IndexedSnapshotTarget) + .filter((target) => + targetMatchesDiscoveredHost(target.executionHostId, scannedExecutionHostId) + ) + } +} + +function targetKey(target: IndexedSnapshotTarget): string { + return JSON.stringify({ + executionHostId: target.executionHostId, + platform: target.platform + } satisfies IndexedSnapshotTarget) +} + +function targetMatchesDiscoveredHost( + targetExecutionHostId: AgentLaunchExecutionHostId, + scannedExecutionHostId: string +): boolean { + if (scannedExecutionHostId === 'local') { + return targetExecutionHostId === 'local' || targetExecutionHostId.startsWith('wsl:') + } + return targetExecutionHostId === scannedExecutionHostId +} diff --git a/src/main/agent-launch/agent-tombstone-reference-index.test.ts b/src/main/agent-launch/agent-tombstone-reference-index.test.ts new file mode 100644 index 00000000000..3d0dbb2ffd4 --- /dev/null +++ b/src/main/agent-launch/agent-tombstone-reference-index.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { + AgentTombstoneReferenceIndex, + type AgentReferenceOwnerScanner +} from './agent-tombstone-reference-index' +import type { AgentReferenceOwnerKind } from '../../shared/agent-reference-snapshot' +import type { CustomTuiAgentId } from '../../shared/types' + +const customA = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd' as CustomTuiAgentId +const customB = 'custom-agent:claude:fedcba98-7654-4321-8fed-cba987654321' as CustomTuiAgentId +const customCodex = 'custom-agent:codex:11111111-2222-4333-8444-555566667777' as CustomTuiAgentId + +function scannerOf( + owner: AgentReferenceOwnerKind, + ids: readonly unknown[] +): AgentReferenceOwnerScanner { + return { owner, scan: () => ({ ok: true, referencedIds: ids }) } +} + +function failingScanner(owner: AgentReferenceOwnerKind): AgentReferenceOwnerScanner { + return { owner, scan: () => ({ ok: false }) } +} + +describe('AgentTombstoneReferenceIndex — custom-id GC counting (invariant across the raw-id refactor)', () => { + it('counts every occurrence of a custom id across owners', () => { + const index = new AgentTombstoneReferenceIndex() + index.register(scannerOf('default', [customA])) + // A quick-command list can reference the same id twice; both count. + index.register(scannerOf('quick-command', [customA, customA, null])) + index.register(scannerOf('automation', [customB])) + expect(index.countReferences(customA)).toBe(3) + expect(index.countReferences(customB)).toBe(1) + }) + + it('returns unknown when any owner scan fails, so GC always retains conservatively', () => { + const index = new AgentTombstoneReferenceIndex() + index.register(scannerOf('default', [customA])) + index.register(failingScanner('automation')) + expect(index.countReferences(customA)).toBe('unknown') + }) + + it('summarizes per owner and reports -1 for an unreadable owner', () => { + const index = new AgentTombstoneReferenceIndex() + index.register(scannerOf('quick-command', [customA, customA])) + index.register(failingScanner('automation')) + index.register(scannerOf('default', [customB])) + const summary = index.summarizeReferences(customA) + expect(summary).toContainEqual({ owner: 'quick-command', count: 2 }) + expect(summary).toContainEqual({ owner: 'automation', count: -1 }) + // An owner with zero references for this id is omitted. + expect(summary.some((entry) => entry.owner === 'default')).toBe(false) + }) +}) + +describe('AgentTombstoneReferenceIndex.countMatchingReferences — base-disable impact (§973)', () => { + // Disabling base 'claude' blocks the base id itself and its derivatives. + const matchesClaudeAndDerivatives = (value: unknown): boolean => + value === 'claude' || value === customA || value === customB + + it('counts the base id and its derivatives across owners', () => { + const index = new AgentTombstoneReferenceIndex() + index.register(scannerOf('default', ['claude'])) + index.register(scannerOf('quick-command', [customA, customCodex, 'codex'])) + index.register(scannerOf('automation', [customB])) + const result = index.countMatchingReferences(matchesClaudeAndDerivatives) + // 'claude' + customA + customB = 3; the unrelated codex references are ignored. + expect(result).toEqual({ count: 3, complete: true }) + }) + + it('excludes owners counted separately (sessions) without affecting completeness', () => { + const index = new AgentTombstoneReferenceIndex() + index.register(scannerOf('default', ['claude'])) + index.register(scannerOf('session', [customA, customB])) + const result = index.countMatchingReferences(matchesClaudeAndDerivatives, { + excludeOwners: new Set(['session']) + }) + expect(result).toEqual({ count: 1, complete: true }) + }) + + it('returns the readable partial with complete=false when a non-excluded owner is unreadable', () => { + const index = new AgentTombstoneReferenceIndex() + index.register(scannerOf('default', ['claude'])) + index.register(failingScanner('automation')) + const result = index.countMatchingReferences(matchesClaudeAndDerivatives) + expect(result).toEqual({ count: 1, complete: false }) + }) + + it('an unreadable EXCLUDED owner does not taint completeness', () => { + const index = new AgentTombstoneReferenceIndex() + index.register(scannerOf('default', ['claude'])) + index.register(failingScanner('session')) + const result = index.countMatchingReferences(matchesClaudeAndDerivatives, { + excludeOwners: new Set(['session']) + }) + expect(result).toEqual({ count: 1, complete: true }) + }) +}) diff --git a/src/main/agent-launch/agent-tombstone-reference-index.ts b/src/main/agent-launch/agent-tombstone-reference-index.ts new file mode 100644 index 00000000000..a0750da9abb --- /dev/null +++ b/src/main/agent-launch/agent-tombstone-reference-index.ts @@ -0,0 +1,122 @@ +// Authoritative reference index over every persisted owner of an agent +// reference. Tombstones are reference-counted recovery records: one is pruned +// only after an authoritative recheck proves zero references in every owner +// store, and an unavailable/corrupt owner store means "retain". Owners added by +// later feature units (worktree pending launches, background attempts, +// orchestration dispatches, sleeping sessions) register additional scanners +// here rather than growing a parallel index. +// +// Scanners enumerate the RAW referenced ids they hold; the index applies the +// counting policy. Tombstone GC and "Review references" count a specific custom +// id (built-ins are never tombstoned); base-disable impact (§973) counts a +// caller-supplied matcher (a base plus its derivatives). Keeping the filter here +// lets one scan answer both without each owner knowing either policy. + +import type { CustomTuiAgentId } from '../../shared/types' +import type { + AgentReferenceOwnerKind, + AgentReferenceSummary +} from '../../shared/agent-reference-snapshot' + +export type { AgentReferenceSummary } + +export type AgentReferenceScanResult = + | { ok: true; referencedIds: readonly unknown[] } + | { ok: false } + +export type AgentReferenceOwnerScanner = { + owner: AgentReferenceOwnerKind + /** Return every id this owner store currently references (raw, unfiltered), or + * ok:false when the store cannot be read (conservative retain). Never throw. */ + scan: () => AgentReferenceScanResult +} + +/** Partial count under a matcher: `count` sums readable owners; `complete` is + * false when any (non-excluded) owner store could not be read, so the true + * total may be higher. */ +export type MatchingReferenceCount = { count: number; complete: boolean } + +export class AgentTombstoneReferenceIndex { + private readonly scanners: AgentReferenceOwnerScanner[] = [] + + register(scanner: AgentReferenceOwnerScanner): void { + this.scanners.push(scanner) + } + + /** Authoritative recheck across every registered owner for a single custom id. + * Returns 'unknown' when any owner scan fails, which callers must treat as + * "retain" (tombstone GC semantics — a partial count must never prune). */ + countReferences(id: CustomTuiAgentId): number | 'unknown' { + let total = 0 + for (const scanner of this.scanners) { + const result = scanner.scan() + if (!result.ok) { + return 'unknown' + } + total += countMatches(result.referencedIds, (value) => value === id) + } + return total + } + + /** Per-owner counts for delete confirmation and "Review references". Owners + * whose scan failed report count -1 so the UI can say "unknown". */ + summarizeReferences(id: CustomTuiAgentId): AgentReferenceSummary[] { + const byOwner = new Map() + for (const scanner of this.scanners) { + const result = scanner.scan() + if (!result.ok) { + byOwner.set(scanner.owner, -1) + continue + } + const count = countMatches(result.referencedIds, (value) => value === id) + const existing = byOwner.get(scanner.owner) + if (existing === -1) { + continue + } + byOwner.set(scanner.owner, (existing ?? 0) + count) + } + const summaries: AgentReferenceSummary[] = [] + for (const [owner, count] of byOwner) { + if (count !== 0) { + summaries.push({ owner, count }) + } + } + return summaries + } + + /** Count references matching an arbitrary predicate — the base-disable impact + * path (§973), where `matches` accepts the base id and any of its derivatives. + * Unlike `countReferences`, an unreadable owner does NOT collapse the whole + * result: it returns the readable partial plus `complete: false` so the caller + * can render "at least N". `excludeOwners` skips owners counted separately + * (the caller reports sessions via the record store's base count instead). */ + countMatchingReferences( + matches: (value: unknown) => boolean, + options?: { excludeOwners?: ReadonlySet } + ): MatchingReferenceCount { + let count = 0 + let complete = true + for (const scanner of this.scanners) { + if (options?.excludeOwners?.has(scanner.owner)) { + continue + } + const result = scanner.scan() + if (!result.ok) { + complete = false + continue + } + count += countMatches(result.referencedIds, matches) + } + return { count, complete } + } +} + +function countMatches(values: readonly unknown[], matches: (value: unknown) => boolean): number { + let count = 0 + for (const value of values) { + if (matches(value)) { + count += 1 + } + } + return count +} diff --git a/src/main/agent-launch/background-agent-launch-forget-retry.test.ts b/src/main/agent-launch/background-agent-launch-forget-retry.test.ts new file mode 100644 index 00000000000..60810e1746e --- /dev/null +++ b/src/main/agent-launch/background-agent-launch-forget-retry.test.ts @@ -0,0 +1,278 @@ +// Injected-attempts integration for the generic background Forget/Retry surface +// (U6, ledger #13). Wires the SAME shared orchestrators the runtime methods use +// (runForgetUnknownAgentLaunch / runWorktreeRetryAgentLaunch) to a real background +// attempt store + operation store, proving the G6 oracles: owner-authorized Forget +// frees exactly one reservation and never spawns/kills; Retry follows the +// persisted-state gating discipline. No production producer is synthesized — every +// attempt here is injected. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { + WorktreeRetryAgentLaunchResult, + WorktreeRetryInFlight +} from './agent-launch-worktree-retry' +import { + AgentLaunchOperationStore, + agentLaunchIdempotencyKey, + type PendingAgentLaunchSnapshot +} from './agent-launch-operation-store' +import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation' +import { runForgetUnknownAgentLaunch } from './agent-launch-worktree-forget' +import { runWorktreeRetryAgentLaunch } from './agent-launch-worktree-retry' +import { BackgroundAgentLaunchStore } from './background-agent-launch-store' + +const ATTEMPT_ID = 'attempt-bg-1' +const WORKTREE_ID = 'repo-1:wt-a' +const OPERATION_ID = 'op-bg-1' +const LAUNCH_TOKEN = 'token-bg-1' +const CLIENT_MUTATION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const FAILURE_ID = 'failure-bg-1' + +function snapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'custom-agent:codex:x', + baseAgent: 'codex', + displayLabel: 'Custom', + mode: 'custom', + argv: ['codex'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: true, + executionHostId: 'ssh:host' + } + } +} + +function unknownFailure(): PersistedAgentLaunchFailure { + return { + code: 'launch_state_unknown', + requestedAgent: 'custom-agent:codex:x', + version: 1, + failureId: FAILURE_ID, + intent: 'background', + occurredAt: 1 + } +} + +function spawnFailedFailure(): PersistedAgentLaunchFailure { + return { + code: 'spawn_failed', + requestedAgent: 'custom-agent:codex:x', + baseAgent: 'codex', + version: 1, + failureId: FAILURE_ID, + intent: 'background', + occurredAt: 1 + } +} + +function pending(): PendingAgentLaunchSnapshot { + return { + operationId: OPERATION_ID, + idempotencyKey: agentLaunchIdempotencyKey({ + principal: { kind: 'local' }, + scope: ATTEMPT_ID, + clientMutationId: CLIENT_MUTATION_ID + }), + scope: ATTEMPT_ID, + clientMutationId: CLIENT_MUTATION_ID, + payloadDigest: 'digest', + launchToken: LAUNCH_TOKEN, + intent: 'background', + snapshot: snapshot() + } +} + +/** Build the exact forget deps the runtime method wires, over real stores. */ +function buildForgetHarness() { + const opStore = new AgentLaunchOperationStore() + const bgStore = new BackgroundAgentLaunchStore({ now: () => 5000 }) + bgStore.create({ + attemptId: ATTEMPT_ID, + worktreeId: WORKTREE_ID, + operationId: OPERATION_ID, + requestedAgent: 'custom-agent:codex:x', + baseAgent: 'codex' + }) + bgStore.markUnknown(ATTEMPT_ID, unknownFailure()) + opStore.beginPending(pending()) + const releaseReservation = vi.fn<(launchToken: string) => void>() + return { opStore, bgStore, releaseReservation } +} + +function forgetDeps(harness: ReturnType) { + const { opStore, bgStore, releaseReservation } = harness + return { + operationStore: opStore, + idempotencyKeyFor: (clientMutationId: string) => + agentLaunchIdempotencyKey({ + principal: { kind: 'local' }, + scope: ATTEMPT_ID, + clientMutationId + }), + loadPendingSnapshot: () => opStore.findPendingByScope(ATTEMPT_ID), + loadFailureCode: () => bgStore.get(ATTEMPT_ID)?.failure?.code, + releaseReservation, + clearPublicState: () => { + bgStore.forget(ATTEMPT_ID) + }, + now: () => 5000 + } +} + +describe('background Forget (ledger #13)', () => { + it('frees exactly one reservation, settles forgotten, retains the failure, never spawns/kills', () => { + const harness = buildForgetHarness() + const result = runForgetUnknownAgentLaunch(forgetDeps(harness), { + scope: ATTEMPT_ID, + expectedOperationId: OPERATION_ID, + clientMutationId: CLIENT_MUTATION_ID + }) + + expect(result).toEqual({ status: 'forgotten' }) + // Exactly one reservation freed (structurally there is no spawn/kill dep). + expect(harness.releaseReservation).toHaveBeenCalledTimes(1) + expect(harness.releaseReservation).toHaveBeenCalledWith(LAUNCH_TOKEN) + // The attempt is forgotten, keeps its unknown failure, and stamps forgottenAt. + const attempt = harness.bgStore.get(ATTEMPT_ID) + expect(attempt?.state).toBe('forgotten') + expect(attempt?.failure?.code).toBe('launch_state_unknown') + expect(attempt?.forgottenAt).toBe(5000) + // Private pending attribution removed. + expect(harness.opStore.findPendingByScope(ATTEMPT_ID)).toBeNull() + }) + + it('replays forgotten on a double submit without re-releasing', () => { + const harness = buildForgetHarness() + const params = { + scope: ATTEMPT_ID, + expectedOperationId: OPERATION_ID, + clientMutationId: CLIENT_MUTATION_ID + } + expect(runForgetUnknownAgentLaunch(forgetDeps(harness), params)).toEqual({ + status: 'forgotten' + }) + harness.releaseReservation.mockClear() + expect(runForgetUnknownAgentLaunch(forgetDeps(harness), params)).toEqual({ + status: 'forgotten' + }) + expect(harness.releaseReservation).not.toHaveBeenCalled() + }) + + it('rejects a stale operation id without mutation', () => { + const harness = buildForgetHarness() + const result = runForgetUnknownAgentLaunch(forgetDeps(harness), { + scope: ATTEMPT_ID, + expectedOperationId: 'op-stale', + clientMutationId: CLIENT_MUTATION_ID + }) + expect(result).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + expect(harness.releaseReservation).not.toHaveBeenCalled() + expect(harness.bgStore.get(ATTEMPT_ID)?.state).toBe('pending') + }) +}) + +function buildRetryHarness(failure: PersistedAgentLaunchFailure) { + const opStore = new AgentLaunchOperationStore() + const bgStore = new BackgroundAgentLaunchStore() + bgStore.create({ + attemptId: ATTEMPT_ID, + worktreeId: WORKTREE_ID, + operationId: OPERATION_ID, + requestedAgent: 'custom-agent:codex:x', + baseAgent: 'codex' + }) + if (failure.code === 'launch_state_unknown') { + bgStore.markUnknown(ATTEMPT_ID, failure) + } else { + bgStore.settleFailed(ATTEMPT_ID, failure) + } + const runLaunch = vi.fn().mockResolvedValue({ + status: 'launched', + receipt: { + requestedAgent: 'custom-agent:codex:x', + baseAgent: 'codex', + notices: [], + launchToken: 'token-retry', + catalogRevision: 1 + } + }) + const inFlight = new Map() + const deps = { + operationStore: opStore, + idempotencyKeyFor: (clientMutationId: string) => + agentLaunchIdempotencyKey({ + principal: { kind: 'local' }, + scope: ATTEMPT_ID, + clientMutationId + }), + findInFlight: (key: string) => inFlight.get(key) ?? null, + registerInFlight: ( + key: string, + digest: string, + promise: Promise + ): void => { + inFlight.set(key, { payloadDigest: digest, promise }) + }, + resolveSettled: () => ({ + status: 'blocked' as const, + failure: { code: 'launch_state_unknown' as const } + }), + loadDurableFailure: () => bgStore.get(ATTEMPT_ID)?.failure ?? null, + resolveRecoveryGate: () => + retryRecoveryGateForFailureCode(bgStore.get(ATTEMPT_ID)?.failure?.code), + runLaunch + } + return { deps, runLaunch, bgStore } +} + +describe('background Retry gating (ledger #13)', () => { + it('blocks a retry while launch_state_unknown WITHOUT running the launch', async () => { + const { deps, runLaunch } = buildRetryHarness(unknownFailure()) + const result = await runWorktreeRetryAgentLaunch(deps, { + scope: ATTEMPT_ID, + expectedFailureId: FAILURE_ID, + clientMutationId: CLIENT_MUTATION_ID, + action: { kind: 'retry-same' } + }) + expect(result).toEqual({ status: 'blocked', failure: { code: 'launch_state_unknown' } }) + expect(runLaunch).not.toHaveBeenCalled() + }) + + it('runs the launch for a retryable settled failure', async () => { + const { deps, runLaunch } = buildRetryHarness(spawnFailedFailure()) + const result = await runWorktreeRetryAgentLaunch(deps, { + scope: ATTEMPT_ID, + expectedFailureId: FAILURE_ID, + clientMutationId: CLIENT_MUTATION_ID, + action: { kind: 'retry-same' } + }) + expect(result).toMatchObject({ status: 'launched' }) + expect(runLaunch).toHaveBeenCalledTimes(1) + }) + + it('rejects a stale failure id without running the launch', async () => { + const { deps, runLaunch } = buildRetryHarness(spawnFailedFailure()) + const result = await runWorktreeRetryAgentLaunch(deps, { + scope: ATTEMPT_ID, + expectedFailureId: 'wrong-id', + clientMutationId: CLIENT_MUTATION_ID, + action: { kind: 'retry-same' } + }) + expect(result).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + expect(runLaunch).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent-launch/background-agent-launch-spawn-declaration.test.ts b/src/main/agent-launch/background-agent-launch-spawn-declaration.test.ts new file mode 100644 index 00000000000..168fb34441e --- /dev/null +++ b/src/main/agent-launch/background-agent-launch-spawn-declaration.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi } from 'vitest' +import { + beginBackgroundDeclarationLaunch, + settleBackgroundDeclarationResolution, + settleBackgroundDeclarationSpawn, + type BackgroundDeclarationDeps +} from './background-agent-launch-spawn-declaration' +import type { AgentLaunchSpawnResolution } from './agent-launch-spawn' +import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { BackgroundAgentLaunchCreateInput } from './background-agent-launch-store' + +const WORKTREE_ID = 'repo-1:wt-a' +const REQUESTED = 'custom-agent:codex:deleted' + +function buildDeps(): BackgroundDeclarationDeps & { + created: BackgroundAgentLaunchCreateInput[] + settledFailed: { attemptId: string; code: string }[] + settledLaunched: string[] + rolledBack: string[] +} { + const created: BackgroundAgentLaunchCreateInput[] = [] + const settledFailed: { attemptId: string; code: string }[] = [] + const settledLaunched: string[] = [] + const rolledBack: string[] = [] + let attemptSeq = 0 + let opSeq = 0 + let failureSeq = 0 + return { + created, + settledFailed, + settledLaunched, + rolledBack, + createAttempt: (input) => created.push(input), + settleLaunched: (attemptId) => settledLaunched.push(attemptId), + settleFailed: (attemptId, failure) => settledFailed.push({ attemptId, code: failure.code }), + rollback: (attemptId) => rolledBack.push(attemptId), + mintAttemptId: () => `attempt-${(attemptSeq += 1)}`, + mintOperationId: () => `op-${(opSeq += 1)}`, + mintFailureId: () => `failure-${(failureSeq += 1)}`, + now: () => 1000 + } +} + +function launchedResolution(): AgentLaunchSpawnResolution { + const receipt: AgentLaunchReceipt = { + requestedAgent: REQUESTED, + baseAgent: 'codex', + notices: [], + launchToken: 'token-1', + catalogRevision: 1, + telemetry: { agentKind: 'codex', usedCustomAgent: true } + } + return { ok: true, plan: {} as AgentStartupPlan, receipt } +} + +describe('background declaration handler', () => { + it('creates the attempt BEFORE resolution with the declared identity and a background intent', () => { + const deps = buildDeps() + const launch = beginBackgroundDeclarationLaunch(deps, { + worktreeId: WORKTREE_ID, + requestedAgent: REQUESTED + }) + + expect(deps.created).toHaveLength(1) + expect(deps.created[0]).toEqual({ + attemptId: launch.attemptId, + worktreeId: WORKTREE_ID, + operationId: 'op-1', + // The stale custom id is preserved verbatim so the failed attempt names it. + requestedAgent: REQUESTED, + baseAgent: null + }) + // The host mints its own intent + attempt-keyed scope; the client never sends either. + expect(launch.intent).toEqual({ + kind: 'background', + attemptId: launch.attemptId, + worktreeId: WORKTREE_ID + }) + expect(launch.scope).toBe(launch.attemptId) + }) + + it('records a durable failed attempt for a resolution failure (survives reload) and retains it', () => { + const deps = buildDeps() + const launch = beginBackgroundDeclarationLaunch(deps, { + worktreeId: WORKTREE_ID, + requestedAgent: REQUESTED + }) + const outcome = settleBackgroundDeclarationResolution(deps, launch.attemptId, { + ok: false, + failure: { code: 'unknown_agent', requestedAgent: REQUESTED } + }) + + expect(outcome).toEqual({ proceed: false, attemptRetained: true }) + expect(deps.settledFailed).toEqual([{ attemptId: launch.attemptId, code: 'unknown_agent' }]) + expect(deps.rolledBack).toEqual([]) + }) + + it('rolls the attempt back for a pre-attempt capacity rejection (admission creates no attempt)', () => { + const deps = buildDeps() + const launch = beginBackgroundDeclarationLaunch(deps, { + worktreeId: WORKTREE_ID, + requestedAgent: REQUESTED + }) + const outcome = settleBackgroundDeclarationResolution(deps, launch.attemptId, { + ok: false, + failure: { code: 'launch_capacity_exceeded' } + }) + + expect(outcome).toEqual({ proceed: false, attemptRetained: false }) + expect(deps.rolledBack).toEqual([launch.attemptId]) + expect(deps.settledFailed).toEqual([]) + }) + + it('rolls the attempt back for a request error', () => { + const deps = buildDeps() + const launch = beginBackgroundDeclarationLaunch(deps, { + worktreeId: WORKTREE_ID, + requestedAgent: REQUESTED + }) + const outcome = settleBackgroundDeclarationResolution(deps, launch.attemptId, { + ok: false, + requestError: { code: 'idempotency_conflict' } + }) + + expect(outcome).toEqual({ proceed: false, attemptRetained: false }) + expect(deps.rolledBack).toEqual([launch.attemptId]) + expect(deps.settledFailed).toEqual([]) + }) + + it('keeps the attempt pending on a successful resolution and settles it on the provider events', () => { + const deps = buildDeps() + const launch = beginBackgroundDeclarationLaunch(deps, { + worktreeId: WORKTREE_ID, + requestedAgent: REQUESTED + }) + const outcome = settleBackgroundDeclarationResolution( + deps, + launch.attemptId, + launchedResolution() + ) + expect(outcome).toEqual({ proceed: true, attemptRetained: true }) + // No settle at resolution time — the attempt stays pending until spawn. + expect(deps.settledLaunched).toEqual([]) + expect(deps.settledFailed).toEqual([]) + + settleBackgroundDeclarationSpawn(deps, launch, 'registered', REQUESTED) + expect(deps.settledLaunched).toEqual([launch.attemptId]) + + // A later spawn-failure settle on a different attempt records spawn_failed. + const other = beginBackgroundDeclarationLaunch(deps, { + worktreeId: WORKTREE_ID, + requestedAgent: REQUESTED + }) + settleBackgroundDeclarationSpawn(deps, other, 'failed', REQUESTED) + expect(deps.settledFailed).toEqual([{ attemptId: other.attemptId, code: 'spawn_failed' }]) + }) +}) + +// Exercise the injected now() so the persisted-failure envelope timestamp is covered. +it('stamps the host-minted persisted failure envelope', () => { + const deps = buildDeps() + const spy = vi.spyOn(deps, 'settleFailed') + const launch = beginBackgroundDeclarationLaunch(deps, { + worktreeId: WORKTREE_ID, + requestedAgent: REQUESTED + }) + settleBackgroundDeclarationResolution(deps, launch.attemptId, { + ok: false, + failure: { code: 'missing_variable', variable: 'worktreePath' } + }) + expect(spy).toHaveBeenCalledWith(launch.attemptId, { + code: 'missing_variable', + variable: 'worktreePath', + version: 1, + failureId: 'failure-1', + intent: 'background', + occurredAt: 1000 + }) +}) diff --git a/src/main/agent-launch/background-agent-launch-spawn-declaration.ts b/src/main/agent-launch/background-agent-launch-spawn-declaration.ts new file mode 100644 index 00000000000..e47dcc46bb3 --- /dev/null +++ b/src/main/agent-launch/background-agent-launch-spawn-declaration.ts @@ -0,0 +1,141 @@ +// Host handler for the ids-free background DECLARATION on a pty:spawn agentLaunch +// request (U6; ledger #8/#13). The client NEVER sends a LaunchIntent or an +// attemptId — it only declares `unattended: {kind:'background'}`; the HOST mints +// the attemptId, creates the generic background attempt BEFORE resolution, builds +// its own LaunchIntent {kind:'background', attemptId, worktreeId}, and settles or +// rolls the attempt back with the spawn outcome, returning the attemptId in-band +// so a future renderer producer can correlate it to its worktree deep link. +// +// There is NO production sender in U6 (ruling #13): launchAgentBackgroundSession +// is automation-owned and github-background is WorktreeMeta-owned, so no genuine +// ownerless background surface exists yet and none is synthesized. The first real +// sender (a U9 deep-link launch) reuses this handler unchanged. Pure and +// injectable; the pty:spawn caller owns the resolver call and the PTY spawn. + +import type { LaunchIntent } from '../../shared/agent-launch-host-contract' +import type { + AgentLaunchFailure, + PersistedAgentLaunchFailure +} from '../../shared/agent-launch-contract' +import type { TuiAgent } from '../../shared/types' +import type { AgentLaunchSpawnResolution } from './agent-launch-spawn' +import type { BackgroundAgentLaunchCreateInput } from './background-agent-launch-store' + +export type BackgroundDeclarationDeps = { + /** Create the attempt in the generic background store (before resolution). */ + createAttempt: (input: BackgroundAgentLaunchCreateInput) => void + /** Settle a launched attempt at the registration event. */ + settleLaunched: (attemptId: string) => void + /** Record a durable `failed` attempt (a resolution failure that is neither a + * request error nor a pre-attempt capacity rejection). */ + settleFailed: (attemptId: string, failure: PersistedAgentLaunchFailure) => void + /** Drop the attempt entirely — used for a request error or capacity rejection + * so neither ever enters attempt history. */ + rollback: (attemptId: string) => void + mintAttemptId: () => string + mintOperationId: () => string + mintFailureId: () => string + now?: () => number +} + +export type BackgroundDeclarationLaunch = { + attemptId: string + intent: Extract + /** The op-store/idempotency scope for this launch is the attempt id, never the + * worktree — a worktree may host several unattended attempts at once. */ + scope: string +} + +/** Create the generic background attempt BEFORE resolution and hand back the + * host-minted intent + scope for the resolver. `requestedAgent` is the client's + * declared selection identity, preserved verbatim (a stale custom id keeps the + * requested-vs-fallback distinction, so its failed attempt names the right id and + * survives reload). `baseAgent` is unknown until resolution, so it starts null. */ +export function beginBackgroundDeclarationLaunch( + deps: BackgroundDeclarationDeps, + input: { worktreeId: string; requestedAgent: TuiAgent } +): BackgroundDeclarationLaunch { + const attemptId = deps.mintAttemptId() + deps.createAttempt({ + attemptId, + worktreeId: input.worktreeId, + operationId: deps.mintOperationId(), + requestedAgent: input.requestedAgent, + baseAgent: null + }) + return { + attemptId, + intent: { kind: 'background', attemptId, worktreeId: input.worktreeId }, + scope: attemptId + } +} + +export type BackgroundDeclarationResolutionOutcome = { + /** True only for a successful resolution — the caller proceeds to spawn and the + * spawn/registration seam settles the still-`pending` attempt. */ + proceed: boolean + /** True when the attempt is retained (settled `failed`); false when it was + * rolled back. Drives whether the caller echoes `backgroundAttemptId`. */ + attemptRetained: boolean +} + +/** Settle the attempt from the PRE-SPAWN resolution outcome. A request error or a + * pre-attempt capacity rejection rolls the attempt back (§U6: request errors and + * capacity rejection stay out of attempt history — "admission rejection creates + * no generic attempt"). Any other resolution failure records a durable `failed` + * attempt that survives reload (oracle 11). A success leaves the attempt + * `pending` for the spawn/registration seam. */ +export function settleBackgroundDeclarationResolution( + deps: BackgroundDeclarationDeps, + attemptId: string, + resolution: AgentLaunchSpawnResolution +): BackgroundDeclarationResolutionOutcome { + if (resolution.ok) { + return { proceed: true, attemptRetained: true } + } + if ('requestError' in resolution) { + deps.rollback(attemptId) + return { proceed: false, attemptRetained: false } + } + if (resolution.failure.code === 'launch_capacity_exceeded') { + deps.rollback(attemptId) + return { proceed: false, attemptRetained: false } + } + deps.settleFailed(attemptId, persistBackgroundFailure(deps, resolution.failure)) + return { proceed: false, attemptRetained: true } +} + +/** Wrap a bare AgentLaunchFailure in the host-minted persisted envelope. The + * intent is always `background` here — this handler only ever mints one kind. */ +export function persistBackgroundFailure( + deps: BackgroundDeclarationDeps, + failure: AgentLaunchFailure +): PersistedAgentLaunchFailure { + const nowFn = deps.now ?? Date.now + return { + ...failure, + version: 1, + failureId: deps.mintFailureId(), + intent: 'background', + occurredAt: nowFn() + } +} + +/** Settle the `pending` attempt from the SPAWN outcome (a provider event): + * registration → `launched`; a spawn/registration throw → durable `failed` + * (`spawn_failed`). Called from the caller's shared launch/settle seam. */ +export function settleBackgroundDeclarationSpawn( + deps: BackgroundDeclarationDeps, + launch: BackgroundDeclarationLaunch, + settlement: 'registered' | 'failed', + requestedAgent: TuiAgent +): void { + if (settlement === 'registered') { + deps.settleLaunched(launch.attemptId) + return + } + deps.settleFailed( + launch.attemptId, + persistBackgroundFailure(deps, { code: 'spawn_failed', requestedAgent }) + ) +} diff --git a/src/main/agent-launch/background-agent-launch-store-host.ts b/src/main/agent-launch/background-agent-launch-store-host.ts new file mode 100644 index 00000000000..5e13d809204 --- /dev/null +++ b/src/main/agent-launch/background-agent-launch-store-host.ts @@ -0,0 +1,15 @@ +// Host-wide singleton generic background-attempt store. One instance per host so +// every background launch producer records its attempt and the reconciler/ +// tombstone index read the same private records. Durable persistence attaches at +// boot; the in-memory instance backs create/settle/forget before that. + +import { BackgroundAgentLaunchStore } from './background-agent-launch-store' + +let store: BackgroundAgentLaunchStore | null = null + +export function getHostBackgroundAgentLaunchStore(): BackgroundAgentLaunchStore { + if (!store) { + store = new BackgroundAgentLaunchStore() + } + return store +} diff --git a/src/main/agent-launch/background-agent-launch-store.test.ts b/src/main/agent-launch/background-agent-launch-store.test.ts new file mode 100644 index 00000000000..bf45fbdd952 --- /dev/null +++ b/src/main/agent-launch/background-agent-launch-store.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest' +import { BackgroundAgentLaunchStore } from './background-agent-launch-store' +import type { BackgroundAgentLaunchCreateInput } from './background-agent-launch-store' +import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import { parsePersistedAgentLaunchFailure } from '../../shared/agent-launch-failure-schema' +import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation' + +// The only keys a persisted launch failure may carry; anything outside this set +// (an env key/value, argv element, label, or path) would be a leak. +const ALLOWED_FAILURE_KEYS = new Set([ + 'code', + 'requestedAgent', + 'baseAgent', + 'variable', + 'field', + 'shell', + 'reason', + 'version', + 'failureId', + 'intent', + 'occurredAt' +]) + +function createInput( + overrides: Partial = {} +): BackgroundAgentLaunchCreateInput { + return { + attemptId: 'attempt-1', + worktreeId: 'repo-a::/srv/app', + operationId: 'op-1', + requestedAgent: 'codex', + baseAgent: 'codex', + ...overrides + } +} + +function failure( + code: PersistedAgentLaunchFailure['code'], + overrides: Partial = {} +): PersistedAgentLaunchFailure { + return { + code, + requestedAgent: 'codex', + baseAgent: 'codex', + version: 1, + failureId: `fail-${code}`, + intent: 'background', + occurredAt: 10, + ...overrides + } +} + +describe('BackgroundAgentLaunchStore', () => { + it('creates an attempt in pending before resolution and is idempotent on attemptId', () => { + const store = new BackgroundAgentLaunchStore({ now: () => 1 }) + const created = store.create(createInput()) + expect(created).toMatchObject({ state: 'pending', failure: null, forgottenAt: null }) + // A replay of the same attempt id returns the existing record unchanged. + const replay = store.create(createInput({ requestedAgent: 'claude' })) + expect(replay.requestedAgent).toBe('codex') + expect(store.all()).toHaveLength(1) + }) + + it('settles launched, clearing any prior failure', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + store.settleFailed('attempt-1', failure('spawn_failed')) + store.settleLaunched('attempt-1') + expect(store.get('attempt-1')).toMatchObject({ state: 'launched', failure: null }) + }) + + it('settles failed with the durable code+hint failure', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + store.settleFailed('attempt-1', failure('spawn_failed')) + expect(store.get('attempt-1')).toMatchObject({ + state: 'failed', + failure: { code: 'spawn_failed' } + }) + }) + + it('markUnknown keeps the attempt pending and coexists with the unknown failure', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + store.markUnknown('attempt-1', failure('launch_state_unknown')) + const attempt = store.get('attempt-1') + expect(attempt?.state).toBe('pending') + expect(attempt?.failure?.code).toBe('launch_state_unknown') + }) + + it('keeps the launch_state_unknown failureId stable across reconcile re-runs', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + store.markUnknown('attempt-1', failure('launch_state_unknown', { failureId: 'first' })) + store.markUnknown('attempt-1', failure('launch_state_unknown', { failureId: 'second' })) + // A churning failureId would reset the client's expectedFailureId guard. + expect(store.get('attempt-1')?.failure?.failureId).toBe('first') + }) + + it('forgets only from launch_state_unknown, retaining the failure and stamping forgottenAt', () => { + const store = new BackgroundAgentLaunchStore({ now: () => 77 }) + store.create(createInput()) + // Cannot forget a plain pending attempt (no unknown failure). + expect(store.forget('attempt-1')).toBe(false) + store.markUnknown('attempt-1', failure('launch_state_unknown')) + expect(store.forget('attempt-1')).toBe(true) + expect(store.get('attempt-1')).toMatchObject({ + state: 'forgotten', + forgottenAt: 77, + failure: { code: 'launch_state_unknown' } + }) + // A second forget is a no-op (no longer unknown). + expect(store.forget('attempt-1')).toBe(false) + }) + + it('cannot forget a failed (not unknown) attempt', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + store.settleFailed('attempt-1', failure('spawn_failed')) + expect(store.forget('attempt-1')).toBe(false) + }) + + it('projects attempts filtered to a worktree', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput({ attemptId: 'a', worktreeId: 'wt-1' })) + store.create(createInput({ attemptId: 'b', worktreeId: 'wt-1' })) + store.create(createInput({ attemptId: 'c', worktreeId: 'wt-2' })) + expect(store.listForWorktree('wt-1').map((a) => a.attemptId)).toEqual(['a', 'b']) + }) + + it('exposes referenced requested agents including forgotten attempts', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput({ attemptId: 'a', requestedAgent: 'custom-agent:codex:1' })) + store.markUnknown('a', failure('launch_state_unknown')) + store.forget('a') + expect(store.referencedRequestedAgents()).toContain('custom-agent:codex:1') + }) + + it('drives the durable sink on every mutation and rebuilds without writing back', () => { + const sink = vi.fn() + const store = new BackgroundAgentLaunchStore() + store.setDurablePersistence(sink) + store.create(createInput()) + store.settleFailed('attempt-1', failure('spawn_failed')) + expect(sink).toHaveBeenCalledTimes(2) + const snapshot = store.durableState() + + const rebuilt = new BackgroundAgentLaunchStore() + const rebuiltSink = vi.fn() + rebuilt.setDurablePersistence(rebuiltSink) + rebuilt.rebuildFrom(snapshot.attempts) + // Rehydrate must not echo back into the sink. + expect(rebuiltSink).not.toHaveBeenCalled() + expect(rebuilt.get('attempt-1')?.state).toBe('failed') + }) + + it('durable-state round trip keeps the attempt failure secret-free and re-normalizable (G6)', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + store.settleFailed('attempt-1', failure('missing_variable', { field: 'env', shell: 'posix' })) + + // The durable snapshot serialized to its on-disk form. + const onDisk = JSON.stringify(store.durableState()) + const parsed = JSON.parse(onDisk) as ReturnType + const persistedFailure = parsed.attempts[0].failure + // No secret text can appear in the failure record: an env key/value, argv + // element, command, label or path would have to surface as a substring. + const persistedFailureText = JSON.stringify(persistedFailure) + for (const marker of ['agentEnv', 'agentArgs', 'argv', 'command', 'label', 'path']) { + expect(persistedFailureText).not.toContain(marker) + } + // The stored failure carries only whitelisted keys and re-normalizes. + for (const key of Object.keys(persistedFailure ?? {})) { + expect(ALLOWED_FAILURE_KEYS.has(key)).toBe(true) + } + expect(parsePersistedAgentLaunchFailure(persistedFailure)).not.toBeNull() + // A tampered on-disk blob with secret text fails normalization. + expect( + parsePersistedAgentLaunchFailure({ ...persistedFailure, agentEnv: { TOKEN: 'x' } }) + ).toBeNull() + }) + + it('reload from disk keeps an unknown-state attempt non-retryable (G6)', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + store.markUnknown('attempt-1', failure('launch_state_unknown')) + + // Persist to disk and rebuild a fresh store from the reloaded snapshot. + const reloaded = JSON.parse(JSON.stringify(store.durableState())) as ReturnType< + BackgroundAgentLaunchStore['durableState'] + > + const rebuilt = new BackgroundAgentLaunchStore() + rebuilt.rebuildFrom(reloaded.attempts) + + const attempt = rebuilt.get('attempt-1') + // Coexistence survives the reload: still pending with the unknown failure. + expect(attempt?.state).toBe('pending') + expect(attempt?.failure?.code).toBe('launch_state_unknown') + // The retry gate still blocks — no auto-relaunch without an explicit owner + // action, so the reconciler cannot re-dispatch the attempt. + expect(retryRecoveryGateForFailureCode(attempt?.failure?.code).kind).toBe( + 'launch_state_unknown' + ) + }) + + it('persistenceForAttempt binds the reconcile slice to one attempt', () => { + const store = new BackgroundAgentLaunchStore() + store.create(createInput()) + const persistence = store.persistenceForAttempt('attempt-1') + persistence.markUnknown(failure('launch_state_unknown')) + expect(store.get('attempt-1')?.failure?.code).toBe('launch_state_unknown') + persistence.settleLaunched() + expect(store.get('attempt-1')?.state).toBe('launched') + }) +}) diff --git a/src/main/agent-launch/background-agent-launch-store.ts b/src/main/agent-launch/background-agent-launch-store.ts new file mode 100644 index 00000000000..adbcc17c7e4 --- /dev/null +++ b/src/main/agent-launch/background-agent-launch-store.ts @@ -0,0 +1,201 @@ +// Host-private store for GENERIC background agent-launch attempts (U6). The +// owner record for unattended launches that have no automation run or +// orchestration dispatch to land in. Kept SEPARATE from the interactive +// two-stage worktree pending launch (plan §U6) so a background failure survives +// reload, points at its worktree, and reconciles through the shared tri-state +// reconciler without conflating with WorktreeMeta. +// +// State model mirrors WorktreeMeta.pendingAgentLaunch + agentLaunchFailure: +// pending → created before resolution; may coexist with a +// launch_state_unknown failure (the reservation and +// private snapshot survive until proof or Forget). +// launched → settled success; failure cleared. +// failed → durable spawn/invalid failure; retryable. +// forgotten → owner forgot an unknown attempt; failure retained, +// forgottenAt stamped; the reservation is freed. +// +// Pure container: admission/liveness/persistence orchestration live in the +// runtime; this store only owns the record lifecycle and its durable sink. + +import type { + BackgroundAgentLaunchAttempt, + BackgroundAgentLaunchState +} from '../../shared/background-agent-launch' +import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { TuiAgent, BuiltInTuiAgent } from '../../shared/types' +import type { ReconcileScopePersistence } from './agent-launch-worktree-reconcile-writer' + +/** Fields fixed when an attempt is created (before resolution). */ +export type BackgroundAgentLaunchCreateInput = { + attemptId: string + worktreeId: string + operationId: string + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent | null +} + +/** The durable half snapshotted for the host-private sink: every attempt. */ +export type BackgroundAgentLaunchStoreDurableState = { + attempts: readonly BackgroundAgentLaunchAttempt[] +} + +export class BackgroundAgentLaunchStore { + private readonly attempts = new Map() + private readonly now: () => number + private onDurableMutation: ((state: BackgroundAgentLaunchStoreDurableState) => void) | null = null + + constructor(deps?: { now?: () => number }) { + this.now = deps?.now ?? (() => Date.now()) + } + + /** Attach (or replace) the durable sink. Not called during rehydrate, so the + * load path never writes back the state it just read. */ + setDurablePersistence(sink: (state: BackgroundAgentLaunchStoreDurableState) => void): void { + this.onDurableMutation = sink + } + + durableState(): BackgroundAgentLaunchStoreDurableState { + return { attempts: [...this.attempts.values()] } + } + + private persistDurable(): void { + this.onDurableMutation?.(this.durableState()) + } + + /** Create the attempt BEFORE resolution. Idempotent on attemptId: a repeated + * create (idempotency replay) returns the existing record unchanged. */ + create(input: BackgroundAgentLaunchCreateInput): BackgroundAgentLaunchAttempt { + const existing = this.attempts.get(input.attemptId) + if (existing) { + return existing + } + const at = this.now() + const attempt: BackgroundAgentLaunchAttempt = { + attemptId: input.attemptId, + worktreeId: input.worktreeId, + operationId: input.operationId, + requestedAgent: input.requestedAgent, + baseAgent: input.baseAgent, + state: 'pending', + failure: null, + createdAt: at, + updatedAt: at, + forgottenAt: null + } + this.attempts.set(attempt.attemptId, attempt) + this.persistDurable() + return attempt + } + + get(attemptId: string): BackgroundAgentLaunchAttempt | null { + return this.attempts.get(attemptId) ?? null + } + + /** Client-safe projection filtered to one worktree (Worktree.backgroundAgentLaunches). */ + listForWorktree(worktreeId: string): BackgroundAgentLaunchAttempt[] { + return [...this.attempts.values()].filter((a) => a.worktreeId === worktreeId) + } + + all(): BackgroundAgentLaunchAttempt[] { + return [...this.attempts.values()] + } + + /** Requested identities of every live attempt, for the tombstone reference + * index's background owner (§217). A forgotten attempt still references its + * id until pruned, keeping a deleted custom id's tombstone retained. */ + referencedRequestedAgents(): TuiAgent[] { + return [...this.attempts.values()].map((a) => a.requestedAgent) + } + + private transition( + attemptId: string, + state: BackgroundAgentLaunchState, + failure: PersistedAgentLaunchFailure | null, + forgottenAt: number | null + ): BackgroundAgentLaunchAttempt | null { + const attempt = this.attempts.get(attemptId) + if (!attempt) { + return null + } + const next: BackgroundAgentLaunchAttempt = { + ...attempt, + state, + failure, + forgottenAt, + updatedAt: this.now() + } + this.attempts.set(attemptId, next) + this.persistDurable() + return next + } + + settleLaunched(attemptId: string): void { + this.transition(attemptId, 'launched', null, null) + } + + settleFailed(attemptId: string, failure: PersistedAgentLaunchFailure): void { + this.transition(attemptId, 'failed', failure, null) + } + + /** Coexistence rule: keep the attempt `pending` and record ONLY the durable + * unknown failure. Keeps an existing launch_state_unknown failureId stable so + * the client's expectedFailureId guard does not churn across reconcile re-runs. */ + markUnknown(attemptId: string, failure: PersistedAgentLaunchFailure): void { + const attempt = this.attempts.get(attemptId) + if (!attempt) { + return + } + const stableFailure = + attempt.failure?.code === 'launch_state_unknown' + ? { ...failure, failureId: attempt.failure.failureId } + : failure + this.transition(attemptId, 'pending', stableFailure, null) + } + + /** Owner-authorized Forget of an unknown attempt. Retains the failure, stamps + * forgottenAt, moves to `forgotten`. Never spawns/kills — the caller frees the + * admission reservation separately. Only valid from a launch_state_unknown + * attempt; other states return false without mutation. */ + forget(attemptId: string): boolean { + const attempt = this.attempts.get(attemptId) + // Valid only from the coexistence state (pending + unknown failure). A + // forgotten attempt retains its unknown failure, so also gate on `pending` + // to reject a second forget. + if ( + !attempt || + attempt.state !== 'pending' || + attempt.failure?.code !== 'launch_state_unknown' + ) { + return false + } + this.transition(attemptId, 'forgotten', attempt.failure, this.now()) + return true + } + + /** Drop an attempt entirely (retention pruning; never a recovery path). */ + delete(attemptId: string): boolean { + const deleted = this.attempts.delete(attemptId) + if (deleted) { + this.persistDurable() + } + return deleted + } + + /** The reconcile persistence slice for one attempt, bound so the shared writer + * drives settle/markUnknown by scope=attemptId. */ + persistenceForAttempt(attemptId: string): ReconcileScopePersistence { + return { + settleLaunched: () => this.settleLaunched(attemptId), + settleFailed: (failure) => this.settleFailed(attemptId, failure), + markUnknown: (failure) => this.markUnknown(attemptId, failure) + } + } + + /** Rehydrate attempts at startup. Not routed through the sink. */ + rebuildFrom(attempts: Iterable): void { + this.attempts.clear() + for (const attempt of attempts) { + this.attempts.set(attempt.attemptId, attempt) + } + } +} diff --git a/src/main/agent-launch/compose-agent-launch-env.test.ts b/src/main/agent-launch/compose-agent-launch-env.test.ts new file mode 100644 index 00000000000..12f93ab32a8 --- /dev/null +++ b/src/main/agent-launch/compose-agent-launch-env.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgentId } from '../../shared/types' +import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import { + composeAgentLaunchEnv, + measurePosixArgEnvBytes, + measureWindowsEnvironmentBlockCodeUnits, + ORCA_PROTECTED_ENV_KEYS +} from './compose-agent-launch-env' +import { checkEnvPayloadTooLarge } from './agent-launch-payload-caps' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf +} from './agent-launch-test-catalog' + +const CID: CustomTuiAgentId = customId('claude', '00000000-0000-4000-8000-0000000000e1') + +function envOf(outcome: ResolveAgentLaunchOutcome): Record { + if (!outcome.ok) { + throw new Error(`expected launch, got ${JSON.stringify(outcome)}`) + } + return { ...outcome.launch.agentEnv } +} + +describe('composeAgentLaunchEnv layering', () => { + it('merges Path/PATH case variants case-insensitively on win32, last layer wins', () => { + const env = composeAgentLaunchEnv({ + platform: 'win32', + inherited: { Path: 'a' }, + agentEnv: { PATH: 'b' } + }) + const keys = Object.keys(env) + expect(keys).toEqual(['PATH']) + expect(env.PATH).toBe('b') + }) + + it('keeps distinct-case keys separate on posix', () => { + const env = composeAgentLaunchEnv({ + platform: 'linux', + inherited: { Path: 'a' }, + agentEnv: { PATH: 'b' } + }) + expect(env.Path).toBe('a') + expect(env.PATH).toBe('b') + }) + + it('regenerates every protected-key case variant last', () => { + const env = composeAgentLaunchEnv({ + platform: 'linux', + inherited: { orca_pane_key: 'spoof', ORCA_PANE_KEY: 'stale', Orca_Pane_Key: 'stale2' }, + orcaControl: { ORCA_PANE_KEY: 'fresh' } + }) + const paneKeys = Object.keys(env).filter((key) => key.toLowerCase() === 'orca_pane_key') + expect(paneKeys).toEqual(['ORCA_PANE_KEY']) + expect(env.ORCA_PANE_KEY).toBe('fresh') + }) + + it('deletes an inherited protected key even without a fresh replacement', () => { + const env = composeAgentLaunchEnv({ + platform: 'linux', + inherited: { ORCA_AGENT_HOOK_TOKEN: 'stale' } + }) + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() + }) + + it('recomputes shadow keys from the effective user env before protected keys', () => { + const env = composeAgentLaunchEnv({ + platform: 'linux', + agentEnv: { CODEX_HOME: '/custom' }, + deriveShadowKeys: (effective) => ({ ORCA_CODEX_HOME_SHADOW: effective.CODEX_HOME ?? '' }) + }) + expect(env.ORCA_CODEX_HOME_SHADOW).toBe('/custom') + }) + + it('never inherits a protected key from a user-overridable provider key list', () => { + for (const key of ORCA_PROTECTED_ENV_KEYS) { + expect(key.startsWith('ORCA_')).toBe(true) + } + }) +}) + +describe('resolver env admission', () => { + it('a custom launch never inherits base agentDefaultEnv', () => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: CID } }), + catalogOf({ customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' } })] }), + settingsOf({ agentDefaultEnv: { claude: { FOO: '1' } } }) + ) + const env = envOf(outcome) + expect(env).toEqual({ BAR: '2' }) + expect(env.FOO).toBeUndefined() + }) + + it('a safe-fallback launch carries no env at all', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: CID }, + intent: { kind: 'interactive', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'default' } + }), + catalogOf({ + customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' } })], + disabledTuiAgents: [CID] + }), + settingsOf({ agentDefaultEnv: { claude: { FOO: '1' } } }) + ) + if (!outcome.ok) { + throw new Error('expected safe-fallback launch') + } + expect(Object.keys(outcome.launch.agentEnv)).toEqual([]) + expect(outcome.launch.policy.env).toBe('none') + }) + + it('withholds custom env for a mobile client without syncEnv and surfaces a notice', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: CID }, + intent: { kind: 'interactive', client: 'mobile' } + }), + catalogOf({ customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' }, syncEnv: false })] }), + settingsOf() + ) + if (!outcome.ok) { + throw new Error('expected launch') + } + expect(Object.keys(outcome.launch.agentEnv)).toEqual([]) + expect(outcome.launch.policy.env).toBe('withheld') + expect(outcome.launch.notices.map((notice) => notice.code)).toContain('env_withheld') + }) + + it('admits custom env for a mobile client when syncEnv is true', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: CID }, + intent: { kind: 'interactive', client: 'mobile' } + }), + catalogOf({ customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' }, syncEnv: true })] }), + settingsOf() + ) + expect(envOf(outcome)).toEqual({ BAR: '2' }) + if (outcome.ok) { + expect(outcome.launch.policy.env).toBe('full') + } + }) + + it('reports env policy none for an empty custom env on desktop', () => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: CID } }), + catalogOf({ customTuiAgents: [customAgent({ id: CID, env: {} })] }), + settingsOf() + ) + if (outcome.ok) { + expect(outcome.launch.policy.env).toBe('none') + } + }) +}) + +describe('env payload caps', () => { + const posixTarget = { + platform: 'linux' as NodeJS.Platform, + execution: 'native' as const, + isRemote: false + } + const winTarget = { + platform: 'win32' as NodeJS.Platform, + execution: 'native' as const, + isRemote: false + } + + it('measures a native-windows environment block including terminators', () => { + // "A=b" (3) + entry NUL (1) + final block terminator (1) = 5 code units. + expect(measureWindowsEnvironmentBlockCodeUnits({ A: 'b' })).toBe(5) + }) + + it('fails closed on an oversized native-windows environment block', () => { + const env = { BIG: 'x'.repeat(40_000) } + expect(checkEnvPayloadTooLarge(['claude'], env, winTarget)).toMatchObject({ + code: 'invalid_agent_env', + reason: 'environment_block_too_large' + }) + }) + + it('fails closed on an oversized POSIX combined argv+env payload', () => { + const env = { BIG: 'x'.repeat(140_000) } + expect(measurePosixArgEnvBytes(['claude'], env)).toBeGreaterThan(131_072) + expect(checkEnvPayloadTooLarge(['claude'], env, posixTarget)).toMatchObject({ + code: 'invalid_agent_env', + reason: 'arg_env_too_large' + }) + }) + + it('accepts an env within the 16 KiB per-agent bound', () => { + const env = { OK: 'x'.repeat(8_000) } + expect(checkEnvPayloadTooLarge(['claude'], env, winTarget)).toBeNull() + expect(checkEnvPayloadTooLarge(['claude'], env, posixTarget)).toBeNull() + }) +}) diff --git a/src/main/agent-launch/compose-agent-launch-env.ts b/src/main/agent-launch/compose-agent-launch-env.ts new file mode 100644 index 00000000000..05be4cc0d40 --- /dev/null +++ b/src/main/agent-launch/compose-agent-launch-env.ts @@ -0,0 +1,154 @@ +// Shared case-aware environment composer for agent launches. Layers inherited, +// provider-default, and admitted custom-agent env, then reapplies the fresh Orca +// control/attribution keys LAST after deleting every case variant so a stale or +// spoofed pane/hook/token value can never survive. Concrete provider-runtime and +// Orca-minted layers are wired in U3; U2 uses this to measure the effective env +// for payload caps and to prove custom/safe-fallback env never inherits base env. + +import { utf8ByteLength } from '../../shared/custom-tui-agent-fields' + +/** Combined final shell argv + effective environment UTF-8 budget for + * POSIX/WSL/SSH targets (no writer cap exists at HEAD). */ +export const POSIX_ARG_ENV_SAFE_MAX_BYTES = 131_072 +/** Command-only UTF-8 budget shared by POSIX/WSL/SSH startup writers. */ +export const POSIX_STARTUP_COMMAND_MAX_BYTES = 131_072 +/** Native-Windows CreateProcess environment block ceiling, measured as + * case-folded `key=value\0…\0` UTF-16 code units including the final terminator. */ +export const WINDOWS_ENVIRONMENT_BLOCK_MAX_CODE_UNITS = 32_767 + +// Fresh Orca attribution/control keys the host regenerates on every PTY launch. +// Enumerated from `rg -oNI 'ORCA_[A-Z0-9_]+' src/main src/shared`; only the +// identity/hook/attribution/control keys the host itself mints per launch belong +// here — user-overridable provider keys (CODEX_HOME, GROK_HOME, OPENCODE_CONFIG_DIR, +// …) are intentionally excluded so R29 keeps them user-settable. Custom-agent env +// can never contain these (validation rejects any `orca_`-prefixed key), so this +// deletion targets the inherited process env and provider layers. +export const ORCA_PROTECTED_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_WORKTREE_ID', + 'ORCA_WORKTREE_PATH', + 'ORCA_ROOT_PATH', + 'ORCA_WORKSPACE_ID', + 'ORCA_WORKSPACE_NAME', + 'ORCA_PROFILE_ID', + 'ORCA_AGENT_MODE', + 'ORCA_AGENT_LAUNCH_TOKEN', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_ATTRIBUTION_SHIM_DIR', + 'ORCA_ENABLE_GIT_ATTRIBUTION', + 'ORCA_GIT_COMMIT_TRAILER', + 'ORCA_SHELL_READY_MARKER', + 'ORCA_USER_DATA_PATH', + 'ORCA_AGENT_TEAMS_SHIM_DIR', + 'ORCA_AGENT_TEAMS_TEAM_ID', + 'ORCA_AGENT_TEAMS_TOKEN' +] as const + +export type EnvLayer = Readonly> + +/** Recomputes base-specific derived shadow keys (e.g. a provider's HOME shadow) + * from the effective user-overridable env. Concrete providers land in U3; the + * hook keeps the composer reusable without importing provider modules. */ +export type DeriveShadowKeys = (effective: Readonly>) => EnvLayer + +export type ComposeAgentLaunchEnvInput = { + platform: NodeJS.Platform + /** Inherited/process env (U3 supplies the real one). */ + inherited?: EnvLayer + /** Non-user provider runtime defaults (U3). */ + providerDefaults?: EnvLayer + /** Admitted custom-agent env; empty for built-in/safe-fallback launches. */ + agentEnv?: EnvLayer + /** Fresh Orca control/attribution values minted per launch (U3). */ + orcaControl?: EnvLayer + deriveShadowKeys?: DeriveShadowKeys +} + +function nullProtoEnv(): Record { + return Object.create(null) as Record +} + +function deleteCaseVariants(target: Record, key: string): void { + const lower = key.toLowerCase() + for (const existing of Object.keys(target)) { + if (existing.toLowerCase() === lower) { + delete target[existing] + } + } +} + +function applyLayer( + target: Record, + layer: EnvLayer | undefined, + caseInsensitive: boolean +): void { + if (!layer) { + return + } + // Own-property iteration only; layers may be null-prototype objects. + for (const key of Object.keys(layer)) { + const value = layer[key] + if (caseInsensitive) { + // Windows env is case-insensitive: drop any colliding variant before + // setting so the later layer wins exactly once. + deleteCaseVariants(target, key) + } else { + delete target[key] + } + target[key] = value + } +} + +/** Compose the effective launch env from ordered layers. Windows merges keys + * case-insensitively; every platform reapplies protected Orca keys last after + * deleting all case variants. */ +export function composeAgentLaunchEnv(input: ComposeAgentLaunchEnvInput): Record { + const caseInsensitive = input.platform === 'win32' + const env = nullProtoEnv() + applyLayer(env, input.inherited, caseInsensitive) + applyLayer(env, input.providerDefaults, caseInsensitive) + applyLayer(env, input.agentEnv, caseInsensitive) + + if (input.deriveShadowKeys) { + // Shadow keys derive from user-overridable values, so recompute after the + // agent env layer and before the protected keys reclaim their names. + applyLayer(env, input.deriveShadowKeys(env), caseInsensitive) + } + + // Protected keys win over every prior case variant on all platforms so a + // stale/spoofed pane/hook/token value can never leak past the host's own. + for (const protectedKey of ORCA_PROTECTED_ENV_KEYS) { + deleteCaseVariants(env, protectedKey) + } + applyLayer(env, input.orcaControl, caseInsensitive) + return env +} + +/** Native-Windows environment-block size in UTF-16 code units: each entry is + * `key=value\0`, with one extra terminating NUL after the final entry. */ +export function measureWindowsEnvironmentBlockCodeUnits(env: EnvLayer): number { + let codeUnits = 0 + for (const key of Object.keys(env)) { + codeUnits += `${key}=${env[key]}`.length + 1 + } + return codeUnits + 1 +} + +/** Combined UTF-8 byte size of the final shell argv plus the effective env, used + * for the POSIX/WSL/SSH payload cap. */ +export function measurePosixArgEnvBytes(argv: readonly string[], env: EnvLayer): number { + let bytes = 0 + for (const arg of argv) { + bytes += utf8ByteLength(arg) + 1 + } + for (const key of Object.keys(env)) { + bytes += utf8ByteLength(`${key}=${env[key]}`) + 1 + } + return bytes +} diff --git a/src/main/agent-launch/legacy-launch-custom-agent-guard.test.ts b/src/main/agent-launch/legacy-launch-custom-agent-guard.test.ts new file mode 100644 index 00000000000..b3bb215de12 --- /dev/null +++ b/src/main/agent-launch/legacy-launch-custom-agent-guard.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { shouldRejectLegacyCustomAgentLaunch } from './legacy-launch-custom-agent-guard' + +const CUSTOM_ID = 'custom-agent:claude:11111111-1111-4111-8111-111111111111' + +describe('shouldRejectLegacyCustomAgentLaunch (U7 legacy built-in path)', () => { + it('rejects a remote client naming a custom id with no agentLaunch', () => { + expect( + shouldRejectLegacyCustomAgentLaunch({ + hasAgentLaunch: false, + requestClientKind: 'mobile', + requestedAgentId: CUSTOM_ID + }) + ).toBe(true) + expect( + shouldRejectLegacyCustomAgentLaunch({ + hasAgentLaunch: false, + requestClientKind: 'runtime', + requestedAgentId: CUSTOM_ID + }) + ).toBe(true) + }) + + it('allows a remote client naming a BUILT-IN id on the legacy path', () => { + expect( + shouldRejectLegacyCustomAgentLaunch({ + hasAgentLaunch: false, + requestClientKind: 'mobile', + requestedAgentId: 'claude' + }) + ).toBe(false) + }) + + it('never rejects a trusted in-process caller (undefined clientKind) even with a custom id', () => { + // Headless automation and desktop pass no authenticated clientKind and keep the + // legacy path with their custom automation.agentId. + expect( + shouldRejectLegacyCustomAgentLaunch({ + hasAgentLaunch: false, + requestClientKind: undefined, + requestedAgentId: CUSTOM_ID + }) + ).toBe(false) + }) + + it('never rejects when a host-atomic agentLaunch drives the launch (custom id is host-resolved)', () => { + // A custom createdWithAgent alongside agentLaunch is legitimate attribution. + expect( + shouldRejectLegacyCustomAgentLaunch({ + hasAgentLaunch: true, + requestClientKind: 'mobile', + requestedAgentId: CUSTOM_ID + }) + ).toBe(false) + }) + + it('does not reject when no agent id is present', () => { + expect( + shouldRejectLegacyCustomAgentLaunch({ + hasAgentLaunch: false, + requestClientKind: 'mobile', + requestedAgentId: undefined + }) + ).toBe(false) + }) +}) diff --git a/src/main/agent-launch/legacy-launch-custom-agent-guard.ts b/src/main/agent-launch/legacy-launch-custom-agent-guard.ts new file mode 100644 index 00000000000..6d35cb6af26 --- /dev/null +++ b/src/main/agent-launch/legacy-launch-custom-agent-guard.ts @@ -0,0 +1,26 @@ +// U7: the legacy built-in worktree.create request path (a client-supplied +// startupAgent/createdWithAgent with NO host-atomic agentLaunch) is preserved for +// one release for BUILT-IN agents only. A remote (mobile/paired-web) client cannot +// have a custom identity host-resolved on that path, so a custom id there is +// rejected at the RPC handler. Trusted in-process callers (desktop, headless +// automation) bypass the handler and keep the legacy path with their custom +// agentId — this guard is scoped to authenticated remote clients. (The session-tabs +// legacy `agent`/`launchAgent` fields already reject custom ids at their schema.) + +import type { AuthenticatedClientKind } from './agent-launch-boundary-contract' +import { isCustomTuiAgentId } from '../../shared/custom-tui-agent-identity' + +/** Whether a legacy (non-agentLaunch) create/launch request must be rejected + * because a remote client named a custom agent id the host cannot resolve there. */ +export function shouldRejectLegacyCustomAgentLaunch(args: { + hasAgentLaunch: boolean + requestClientKind: AuthenticatedClientKind + requestedAgentId: string | undefined +}): boolean { + return ( + !args.hasAgentLaunch && + args.requestClientKind !== undefined && + args.requestedAgentId !== undefined && + isCustomTuiAgentId(args.requestedAgentId) + ) +} diff --git a/src/main/agent-launch/resolve-agent-command.ts b/src/main/agent-launch/resolve-agent-command.ts new file mode 100644 index 00000000000..29310c7693d --- /dev/null +++ b/src/main/agent-launch/resolve-agent-command.ts @@ -0,0 +1,279 @@ +// Launch command assembly: turn a launch decision plus its templates into the +// final structured argv (executable + user args), from the first untrusted byte +// to the last element. Custom overrides stay one executable element; legacy +// built-in prefixes tokenize per target shell; catalog argv is used verbatim. +// Target-shell quoting is deferred to the startup planner — this module never +// concatenates shell text. + +import { posix as pathPosix, win32 as pathWin32 } from 'node:path' +import type { AgentArgv } from '../../shared/agent-launch-host-contract' +import type { AgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import { CMD_UNENCODABLE_CHAR_RE } from '../../shared/tui-agent-startup-shell' +import { getTuiAgentLaunchArgv, type TuiAgentConfig } from '../../shared/tui-agent-config' +import { + canonicalizeCommandOverride, + MAX_COMMAND_PATH_LENGTH +} from '../../shared/custom-tui-agent-fields' +import { tokenizeAgentArgsTemplate } from '../../shared/agent-args-tokenizer' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { tokenizeLegacyAgentPrefix } from '../../shared/legacy-agent-prefix-tokenizer' +import { + collectReferencedVariables, + firstMissingVariable, + interpolateVariables, + LAUNCH_VARIABLE_ORDER, + type LaunchVariableName, + type LaunchVariableValues +} from './resolve-agent-variables' + +// NUL, CR/LF, and the rest of C0/C1/DEL are never valid in a resolved executable. +// eslint-disable-next-line no-control-regex -- rejecting control chars is the point +const EXECUTABLE_CONTROL_RE = /[\0\r\n\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/ + +export type AssembleCommandInput = { + config: TuiAgentConfig + platform: NodeJS.Platform + isRemote: boolean + shell: AgentStartupShell + targetHomePath: string | null + /** Custom executable override (raw stored value); one argv element. */ + commandOverride?: string | null + /** Legacy built-in command-prefix override (settings.agentCmdOverrides[base]). */ + prefixOverride?: string | null + /** Args template; v1 grammar for custom, legacy grammar for built-in. */ + argsTemplate: string + isCustomArgs: boolean + /** Per-launch source-control recipe args (U7); v1 grammar, appended as a + * distinct band after the definition argv and before the prompt argv. */ + perLaunchArgs?: string + /** Env values scanned so a variable referenced only in env is still required. */ + envValues: readonly string[] + values: LaunchVariableValues +} + +export type AssembleCommandResult = + | { + ok: true + argv: AgentArgv + prefixSource: 'catalog' | 'configured' | 'override' + referenced: LaunchVariableName[] + } + | { ok: false; failure: AgentLaunchFailure } + +type TildeResult = { ok: true; value: string } | { ok: false; failure: AgentLaunchFailure } + +function expandTilde(arg: string, shell: AgentStartupShell, home: string | null): TildeResult { + if (arg[0] !== '~') { + return { ok: true, value: arg } + } + const lib = shell === 'posix' ? pathPosix : pathWin32 + if (arg === '~') { + return home === null + ? { ok: false, failure: { code: 'missing_target_home' } } + : { ok: true, value: home } + } + const second = arg[1] + const isSep = second === '/' || (shell !== 'posix' && second === '\\') + if (!isSep) { + // ~user/ forms cannot be expanded without a passwd lookup; fail loudly + // rather than emit a broken executable path. + return { + ok: false, + failure: { code: 'invalid_command_override', field: 'commandOverride', reason: 'tilde_user' } + } + } + if (home === null) { + return { ok: false, failure: { code: 'missing_target_home' } } + } + return { ok: true, value: lib.join(home, arg.slice(2)) } +} + +function validateResolvedExecutable(value: string): AgentLaunchFailure | null { + if (value.length === 0) { + return { code: 'invalid_command_override', field: 'commandOverride', reason: 'empty' } + } + if (value.length > MAX_COMMAND_PATH_LENGTH) { + return { code: 'invalid_command_override', field: 'commandOverride', reason: 'bounds' } + } + if (EXECUTABLE_CONTROL_RE.test(value)) { + return { code: 'invalid_command_override', field: 'commandOverride', reason: 'control_char' } + } + return null +} + +function tokenizeArgs( + template: string, + isCustom: boolean, + shell: AgentStartupShell +): { ok: true; tokens: string[] } | { ok: false; failure: AgentLaunchFailure } { + if (template.trim().length === 0) { + return { ok: true, tokens: [] } + } + if (isCustom) { + const result = tokenizeAgentArgsTemplate(template) + if (!result.ok) { + return { + ok: false, + failure: { code: 'invalid_agent_args', field: 'args', reason: result.reason, shell } + } + } + return { ok: true, tokens: result.tokens } + } + // Built-in args use the shipped shell-aware grammar (#7862): posix keeps the + // shared template grammar, Windows shells keep backslashes literal. + const result = tokenizeStartupCommand(template.trim(), shell) + if (!result.ok) { + // Every shipped grammar only fails on an unclosed quote. + return { + ok: false, + failure: { code: 'invalid_agent_args', field: 'args', reason: 'unterminated_quote', shell } + } + } + return { ok: true, tokens: result.tokens } +} + +function buildPrefix( + input: AssembleCommandInput +): + | { ok: true; argv: string[]; source: 'catalog' | 'configured' | 'override' } + | { ok: false; failure: AgentLaunchFailure } { + const { shell, targetHomePath } = input + if (input.commandOverride !== undefined && input.commandOverride !== null) { + const canonical = canonicalizeCommandOverride(input.commandOverride) + const interpolated = interpolateVariables(canonical, input.values) + if (shell === 'cmd' && CMD_UNENCODABLE_CHAR_RE.test(interpolated)) { + return { + ok: false, + failure: { + code: 'invalid_agent_args', + field: 'commandOverride', + reason: 'cmd_metachar', + shell: 'cmd' + } + } + } + const expanded = expandTilde(interpolated, shell, targetHomePath) + if (!expanded.ok) { + return expanded + } + const invalid = validateResolvedExecutable(expanded.value) + if (invalid) { + return { ok: false, failure: invalid } + } + return { ok: true, argv: [expanded.value], source: 'override' } + } + + if (input.prefixOverride && input.prefixOverride.trim().length > 0) { + const tokenized = tokenizeLegacyAgentPrefix(input.prefixOverride, shell) + if (!tokenized.ok) { + return { + ok: false, + failure: { + code: 'invalid_command_override', + field: 'commandOverride', + reason: tokenized.reason, + shell + } + } + } + if (tokenized.tokens.length > 0) { + const expanded = expandTilde(tokenized.tokens[0], shell, targetHomePath) + if (!expanded.ok) { + return expanded + } + return { + ok: true, + argv: [expanded.value, ...tokenized.tokens.slice(1)], + source: 'configured' + } + } + } + + const catalogArgv = getTuiAgentLaunchArgv(input.config, input.platform, { + isRemote: input.isRemote + }) + const expanded = expandTilde(catalogArgv[0], shell, targetHomePath) + if (!expanded.ok) { + return expanded + } + return { ok: true, argv: [expanded.value, ...catalogArgv.slice(1)], source: 'catalog' } +} + +/** Assemble the final structured argv, or return the first typed failure. Runs + * the missing-variable scan across command/args/env before any interpolation. */ +export function assembleCommand(input: AssembleCommandInput): AssembleCommandResult { + const args = tokenizeArgs(input.argsTemplate, input.isCustomArgs, input.shell) + if (!args.ok) { + return args + } + + // Recipe args are a per-launch band validated through the SAME v1 (custom) + // grammar as definition custom args, so they surface the identical + // invalid_agent_args diagnostics and count against the same command/env caps. + const recipeArgs = tokenizeArgs(input.perLaunchArgs ?? '', true, input.shell) + if (!recipeArgs.ok) { + return recipeArgs + } + + const scanTexts = [ + ...(input.commandOverride ? [canonicalizeCommandOverride(input.commandOverride)] : []), + ...args.tokens, + ...recipeArgs.tokens, + ...input.envValues + ] + const referenced = collectReferencedVariables(scanTexts) + const missing = firstMissingVariable(referenced, input.values) + if (missing) { + return { ok: false, failure: { code: 'missing_variable', variable: missing } } + } + + // A referenced variable value carrying a cmd-unencodable char fails closed on + // either the custom or legacy built-in path before any writer runs. + if (input.shell === 'cmd') { + for (const name of LAUNCH_VARIABLE_ORDER) { + const value = input.values[name] + if (referenced.has(name) && value && CMD_UNENCODABLE_CHAR_RE.test(value)) { + return { + ok: false, + failure: { code: 'invalid_agent_args', reason: 'cmd_metachar', shell: 'cmd' } + } + } + } + } + + const prefix = buildPrefix(input) + if (!prefix.ok) { + return prefix + } + + const argTokens = args.tokens.map((token) => interpolateVariables(token, input.values)) + const recipeArgTokens = recipeArgs.tokens.map((token) => + interpolateVariables(token, input.values) + ) + // Recipe args always use the v1 grammar, so they get the same cmd-metachar + // fail-closed check the custom definition-args band gets. + const cmdSensitiveTokens = [...(input.isCustomArgs ? argTokens : []), ...recipeArgTokens] + if (input.shell === 'cmd') { + for (const token of cmdSensitiveTokens) { + if (CMD_UNENCODABLE_CHAR_RE.test(token)) { + return { + ok: false, + failure: { + code: 'invalid_agent_args', + field: 'args', + reason: 'cmd_metachar', + shell: 'cmd' + } + } + } + } + } + + return { + ok: true, + argv: [prefix.argv[0], ...prefix.argv.slice(1), ...argTokens, ...recipeArgTokens] as AgentArgv, + prefixSource: prefix.source, + referenced: LAUNCH_VARIABLE_ORDER.filter((name) => referenced.has(name)) + } +} diff --git a/src/main/agent-launch/resolve-agent-env-admission.ts b/src/main/agent-launch/resolve-agent-env-admission.ts new file mode 100644 index 00000000000..5f200bfa8f1 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-env-admission.ts @@ -0,0 +1,67 @@ +// Env admission policy: which user-configured agent env a launch may carry, and +// the client identity that decides it. Custom launches expose definition.env +// only when the initiating client is trusted (desktop/cli/host) or the agent +// opted into paired-device env sync; built-in launches keep their existing +// per-agent default env; safe-fallback launches carry no env at all. + +import type { LaunchIntent } from '../../shared/agent-launch-host-contract' + +export type LaunchClientKind = 'desktop' | 'paired-web' | 'mobile' | 'cli' | 'host-service' + +export function clientOfIntent(intent: LaunchIntent): LaunchClientKind { + if (intent.kind === 'interactive' || intent.kind === 'resume') { + return intent.client + } + if (intent.kind === 'cli') { + return 'cli' + } + return 'host-service' +} + +export type EnvAdmission = { + env: Record + policy: 'full' | 'withheld' | 'none' + withheld: boolean +} + +function emptyEnv(): Record { + return Object.create(null) as Record +} + +function copyEnv(source: Readonly>): Record { + const target = emptyEnv() + for (const key of Object.keys(source)) { + target[key] = source[key] + } + return target +} + +/** Admit a custom agent's env per the initiating client. A paired/mobile client + * must have `syncEnv` to receive values; otherwise they are withheld (not + * dropped silently) and a notice is surfaced. Empty env resolves to 'none'. */ +export function admitCustomEnv( + configuredEnv: Readonly>, + client: LaunchClientKind, + syncEnv: boolean +): EnvAdmission { + const hasEntries = Object.keys(configuredEnv).length > 0 + const trusted = client !== 'paired-web' && client !== 'mobile' + if (!trusted && !syncEnv) { + return { env: emptyEnv(), policy: hasEntries ? 'withheld' : 'none', withheld: hasEntries } + } + if (!hasEntries) { + return { env: emptyEnv(), policy: 'none', withheld: false } + } + return { env: copyEnv(configuredEnv), policy: 'full', withheld: false } +} + +/** Built-in launches keep their existing per-agent default env (yolo/env + * defaults), always full — the paired-sync gate is a custom-agent concept. */ +export function admitBuiltInEnv(configuredEnv: Readonly>): EnvAdmission { + const hasEntries = Object.keys(configuredEnv).length > 0 + return { + env: hasEntries ? copyEnv(configuredEnv) : emptyEnv(), + policy: hasEntries ? 'full' : 'none', + withheld: false + } +} diff --git a/src/main/agent-launch/resolve-agent-launch-assembly.test.ts b/src/main/agent-launch/resolve-agent-launch-assembly.test.ts new file mode 100644 index 00000000000..769badb7218 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-assembly.test.ts @@ -0,0 +1,587 @@ +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgentId } from '../../shared/types' +import type { + AgentLaunchExecutionHostId, + ResolveAgentLaunchRequest +} from '../../shared/agent-launch-host-contract' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import { assembleCommand } from './resolve-agent-command' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf +} from './agent-launch-test-catalog' + +function argvOf(outcome: ResolveAgentLaunchOutcome): string[] { + if (!outcome.ok) { + throw new Error(`expected launch, got ${JSON.stringify(outcome)}`) + } + return [...outcome.launch.argv] +} + +function failureOf(outcome: ResolveAgentLaunchOutcome): { + code: string + reason?: string + variable?: string + shell?: string +} { + if (outcome.ok || !('failure' in outcome)) { + throw new Error(`expected a launch failure, got ${JSON.stringify(outcome)}`) + } + return outcome.failure +} + +const CID: CustomTuiAgentId = customId('claude', '00000000-0000-4000-8000-0000000000c1') + +/** Resolve a launch for a live custom agent built from `overrides`. */ +function resolveCustom( + overrides: Partial[0]>, + request: Partial = {} +): ResolveAgentLaunchOutcome { + const agent = customAgent({ id: CID, ...overrides }) + return resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: CID }, ...request }), + catalogOf({ customTuiAgents: [agent] }), + settingsOf() + ) +} + +describe('command override assembly', () => { + it('keeps an executable path with spaces as one argv element', () => { + const outcome = resolveCustom({ commandOverride: '/opt/my agent/bin/run' }) + expect(argvOf(outcome)).toEqual(['/opt/my agent/bin/run']) + }) + + it('keeps ordinary metacharacters as data on posix', () => { + const outcome = resolveCustom({ commandOverride: '/opt/a&b/run' }) + expect(argvOf(outcome)).toEqual(['/opt/a&b/run']) + }) + + it('interpolates repoPath into the override as one element even with spaces', () => { + const outcome = resolveCustom( + { commandOverride: '{repoPath}/bin/run' }, + { variables: { repoPath: '/work spaces/repo' } } + ) + expect(argvOf(outcome)).toEqual(['/work spaces/repo/bin/run']) + }) +}) + +describe('legacy built-in prefix', () => { + it('rejects whitespace-delimited operator syntax as invalid_command_override', () => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: 'claude' } }), + catalogOf({}), + settingsOf({ agentCmdOverrides: { claude: 'claude && rm' } }) + ) + expect(failureOf(outcome)).toMatchObject({ + code: 'invalid_command_override', + reason: 'shell_operator' + }) + }) + + it('tokenizes a multi-token wrapper prefix into structured argv', () => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: 'claude' } }), + catalogOf({}), + settingsOf({ + agentCmdOverrides: { claude: 'nice -n 10 claude' }, + agentDefaultArgs: { claude: '' } + }) + ) + expect(argvOf(outcome)).toEqual(['nice', '-n', '10', 'claude']) + }) +}) + +describe('tilde expansion', () => { + it('expands ~/ from posix target home', () => { + const outcome = resolveCustom( + { commandOverride: '~/bin/agent' }, + { targetHomePath: '/home/dev' } + ) + expect(argvOf(outcome)).toEqual(['/home/dev/bin/agent']) + }) + + it('expands bare ~ to the target home', () => { + const outcome = resolveCustom({ commandOverride: '~' }, { targetHomePath: '/home/dev' }) + expect(argvOf(outcome)).toEqual(['/home/dev']) + }) + + it('expands ~\\ on a windows powershell target', () => { + const outcome = resolveCustom( + { commandOverride: '~\\bin\\agent.exe' }, + { platform: 'win32', shell: 'powershell', targetHomePath: 'C:\\Users\\me' } + ) + expect(argvOf(outcome)).toEqual(['C:\\Users\\me\\bin\\agent.exe']) + }) + + it('rejects ~user/ forms with tilde_user', () => { + const outcome = resolveCustom( + { commandOverride: '~alice/bin/agent' }, + { targetHomePath: '/home/dev' } + ) + expect(failureOf(outcome)).toMatchObject({ + code: 'invalid_command_override', + reason: 'tilde_user' + }) + }) + + it('fails missing_target_home when home is unavailable', () => { + const outcome = resolveCustom({ commandOverride: '~/bin/agent' }, { targetHomePath: null }) + expect(failureOf(outcome).code).toBe('missing_target_home') + }) + + it('fails missing_target_home for a ~-prefixed WSL executable with no distro home', () => { + const outcome = resolveCustom( + { commandOverride: '~/bin/agent' }, + { + platform: 'linux', + shell: 'posix', + executionHostId: 'wsl:Ubuntu' as AgentLaunchExecutionHostId, + targetHomePath: null + } + ) + expect(failureOf(outcome).code).toBe('missing_target_home') + }) +}) + +describe('custom args grammar', () => { + it('groups quoted tokens and keeps a spaced value as one element', () => { + const outcome = resolveCustom({ commandOverride: '/bin/agent', args: '"a b" c' }) + expect(argvOf(outcome)).toEqual(['/bin/agent', 'a b', 'c']) + }) + + it('retains an empty quoted token', () => { + const outcome = resolveCustom({ commandOverride: '/bin/agent', args: '"" x' }) + expect(argvOf(outcome)).toEqual(['/bin/agent', '', 'x']) + }) + + it('preserves literal windows backslashes and a trailing backslash', () => { + const outcome = resolveCustom({ commandOverride: '/bin/agent', args: 'C:\\Users\\me\\' }) + expect(argvOf(outcome)).toEqual(['/bin/agent', 'C:\\Users\\me\\']) + }) + + it('keeps = inside a single argument', () => { + const outcome = resolveCustom({ commandOverride: '/bin/agent', args: 'FOO=bar' }) + expect(argvOf(outcome)).toEqual(['/bin/agent', 'FOO=bar']) + }) + + it('keeps a repoPath value with spaces as one argv element', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent', args: '{repoPath}' }, + { variables: { repoPath: '/a b/c' } } + ) + expect(argvOf(outcome)).toEqual(['/bin/agent', '/a b/c']) + }) + + it('keeps a cmd-metachar-free arg containing % as data on posix', () => { + const outcome = resolveCustom({ commandOverride: '/bin/agent', args: '--pct 50%' }) + expect(argvOf(outcome)).toEqual(['/bin/agent', '--pct', '50%']) + }) +}) + +describe('per-launch recipe args band (U7)', () => { + it('appends recipe args as a distinct band after the definition argv', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent', args: '--def one' }, + { perLaunchArgs: '--recipe two' } + ) + expect(argvOf(outcome)).toEqual(['/bin/agent', '--def', 'one', '--recipe', 'two']) + }) + + it('tokenizes recipe args through the v1 grammar (quoted value is one element)', () => { + const outcome = resolveCustom({ commandOverride: '/bin/agent' }, { perLaunchArgs: '"a b" c' }) + expect(argvOf(outcome)).toEqual(['/bin/agent', 'a b', 'c']) + }) + + it('interpolates a variable referenced only in recipe args and keeps spaces intact', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent' }, + { perLaunchArgs: '{worktreePath}', variables: { worktreePath: '/w s/t' } } + ) + expect(argvOf(outcome)).toEqual(['/bin/agent', '/w s/t']) + }) + + it('requires a variable referenced only in recipe args', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent' }, + { perLaunchArgs: '{worktreePath}', variables: {} } + ) + expect(failureOf(outcome)).toMatchObject({ code: 'missing_variable', variable: 'worktreePath' }) + }) +}) + +// The catalog pre-validates a live definition, so quoted-line-break/control args +// never reach the resolver as a live agent — they become repair-required. Exercise +// the resolver's defensive re-tokenization directly for persisted/remote data. +describe('assembleCommand defensive re-validation', () => { + const base = { + config: TUI_AGENT_CONFIG.claude, + platform: 'linux' as NodeJS.Platform, + isRemote: false, + shell: 'posix' as const, + targetHomePath: '/home/dev', + prefixOverride: null, + envValues: [] as string[], + values: { repoPath: null, worktreePath: null } + } + + it('rejects a quoted line break in custom args', () => { + const result = assembleCommand({ + ...base, + commandOverride: '/bin/agent', + argsTemplate: '"a\nb"', + isCustomArgs: true + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.failure).toMatchObject({ + code: 'invalid_agent_args', + reason: 'quoted_line_break' + }) + } + }) + + it('rejects a cmd-metachar custom args token', () => { + const result = assembleCommand({ + ...base, + shell: 'cmd', + commandOverride: 'C:\\bin\\agent.exe', + argsTemplate: '--x a%b', + isCustomArgs: true + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.failure).toMatchObject({ + code: 'invalid_agent_args', + reason: 'cmd_metachar', + shell: 'cmd' + }) + } + }) + + it('rejects a quoted line break in recipe args with the same diagnostics as definition args', () => { + const result = assembleCommand({ + ...base, + commandOverride: '/bin/agent', + argsTemplate: '--def', + isCustomArgs: true, + perLaunchArgs: '"a\nb"' + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.failure).toMatchObject({ + code: 'invalid_agent_args', + field: 'args', + reason: 'quoted_line_break' + }) + } + }) + + it('rejects a cmd-metachar recipe args token even when definition args are built-in', () => { + const result = assembleCommand({ + ...base, + shell: 'cmd', + commandOverride: 'C:\\bin\\agent.exe', + argsTemplate: '--def', + isCustomArgs: false, + perLaunchArgs: '--x a%b' + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.failure).toMatchObject({ + code: 'invalid_agent_args', + reason: 'cmd_metachar', + shell: 'cmd' + }) + } + }) +}) + +describe('missing variables', () => { + it('reports the first missing variable in deterministic order with no partial output', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent', args: '{repoPath} {worktreePath}' }, + { variables: { worktreePath: '/wt' } } + ) + expect(failureOf(outcome)).toMatchObject({ code: 'missing_variable', variable: 'repoPath' }) + }) + + it('treats an empty-string variable value as missing', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent', args: '{worktreePath}' }, + { variables: { worktreePath: '' } } + ) + expect(failureOf(outcome)).toMatchObject({ code: 'missing_variable', variable: 'worktreePath' }) + }) +}) + +describe('detection gate', () => { + const detectedWithoutClaude = new Set() as ReadonlySet<'claude'> + + it('fails base_agent_unavailable for stock argv when the base is not detected', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + detectedStockBaseAgents: detectedWithoutClaude + }), + catalogOf({}), + settingsOf() + ) + expect(failureOf(outcome).code).toBe('base_agent_unavailable') + }) + + it('bypasses detection for a configured built-in prefix', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + detectedStockBaseAgents: detectedWithoutClaude + }), + catalogOf({}), + settingsOf({ agentCmdOverrides: { claude: '/opt/claude' }, agentDefaultArgs: { claude: '' } }) + ) + expect(argvOf(outcome)).toEqual(['/opt/claude']) + }) + + it('bypasses detection for a custom executable override', () => { + const outcome = resolveCustom( + { commandOverride: '/opt/claude' }, + { detectedStockBaseAgents: detectedWithoutClaude } + ) + expect(argvOf(outcome)).toEqual(['/opt/claude']) + }) + + it('bypasses detection when custom env supplies a PATH override', () => { + const outcome = resolveCustom( + { env: { PATH: '/opt/bin' } }, + { detectedStockBaseAgents: detectedWithoutClaude } + ) + expect(argvOf(outcome)).toEqual(['claude']) + }) + + it('fails base_agent_unavailable for a safe fallback whose stock base is not detected', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: CID }, + intent: { kind: 'interactive', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'default' }, + detectedStockBaseAgents: detectedWithoutClaude + }), + catalogOf({ customTuiAgents: [customAgent({ id: CID })], disabledTuiAgents: [CID] }), + settingsOf() + ) + expect(failureOf(outcome).code).toBe('base_agent_unavailable') + }) +}) + +describe('fixed catalog subcommands', () => { + const cases: [string, string[]][] = [ + ['kiro', ['kiro-cli', 'chat', '--tui']], + ['command-code', ['command-code', '--trust']], + ['hermes', ['hermes', '--tui']] + ] + for (const [agent, argv] of cases) { + it(`${agent} launches with its fixed subcommand argv`, () => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: agent as never } }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { [agent]: '' } as never }) + ) + expect(argvOf(outcome)).toEqual(argv) + }) + } + + it('uses remote linux orca and local linux orca-ide for claude-agent-teams', () => { + const remote = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: 'claude-agent-teams' }, isRemote: true }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { 'claude-agent-teams': '' } }) + ) + expect(argvOf(remote)).toEqual(['orca', 'claude-teams']) + const local = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: 'claude-agent-teams' }, isRemote: false }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { 'claude-agent-teams': '' } }) + ) + expect(argvOf(local)).toEqual(['orca-ide', 'claude-teams']) + const win = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude-agent-teams' }, + platform: 'win32', + shell: 'powershell' + }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { 'claude-agent-teams': '' } }) + ) + expect(argvOf(win)).toEqual(['orca.cmd', 'claude-teams']) + }) +}) + +describe('built-in default args', () => { + it('appends configured default args as separate argv elements', () => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: 'claude' } }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { claude: '--model sonnet' } }) + ) + expect(argvOf(outcome)).toEqual(['claude', '--model', 'sonnet']) + }) + + it('keeps backslashes literal on a windows powershell target (#7862 grammar)', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + platform: 'win32', + shell: 'powershell', + targetHomePath: 'C:\\Users\\me' + }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { claude: '--config C:\\tools\\x' } }) + ) + expect(argvOf(outcome)).toEqual(['claude', '--config', 'C:\\tools\\x']) + }) + + it('keeps backslashes literal on a windows cmd target (#7862 grammar)', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + platform: 'win32', + shell: 'cmd', + targetHomePath: 'C:\\Users\\me' + }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { claude: '--config C:\\tools\\x' } }) + ) + expect(argvOf(outcome)).toEqual(['claude', '--config', 'C:\\tools\\x']) + }) +}) + +describe('cmd fail-closed', () => { + const cmdRequest = { platform: 'win32' as NodeJS.Platform, shell: 'cmd' as const } + + for (const char of ['%', '!', '"', '^']) { + it(`rejects a custom override containing ${char} with cmd_metachar`, () => { + const outcome = resolveCustom({ commandOverride: `C:\\bin\\a${char}b.exe` }, cmdRequest) + expect(failureOf(outcome)).toMatchObject({ + code: 'invalid_agent_args', + reason: 'cmd_metachar', + shell: 'cmd' + }) + }) + } + + it('lets & | < > ( ) survive as data from an interpolated path on cmd', () => { + // A worktree path with these neutral cmd chars must reach the program intact; + // the override validator rejects them only as literal whitespace-delimited + // operators, so they arrive via an interpolated variable value. + const outcome = resolveCustom( + { commandOverride: '{repoPath}\\run.exe' }, + { ...cmdRequest, variables: { repoPath: 'C:\\Foo & Bar (x)' } } + ) + expect(argvOf(outcome)).toEqual(['C:\\Foo & Bar (x)\\run.exe']) + }) + + it('rejects a cmd-metachar variable value on the built-in path', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + ...cmdRequest, + variables: { repoPath: 'C:\\a%b' } + }), + catalogOf({}), + settingsOf({ agentDefaultArgs: { claude: '--root {repoPath}' } }) + ) + expect(failureOf(outcome)).toMatchObject({ + code: 'invalid_agent_args', + reason: 'cmd_metachar', + shell: 'cmd' + }) + }) +}) + +describe('WSL variable translation', () => { + it('translates a drive-letter repoPath into distro form before substitution', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent', args: '{repoPath}' }, + { + platform: 'linux', + shell: 'posix', + executionHostId: 'wsl:Ubuntu' as AgentLaunchExecutionHostId, + variables: { repoPath: 'C:\\repo\\app' } + } + ) + const launched = argvOf(outcome) + expect(launched).toEqual(['/bin/agent', '/mnt/c/repo/app']) + if (outcome.ok) { + expect(outcome.launch.snapshot.target.execution).toBe('wsl') + expect(outcome.launch.variables.values.repoPath).toBe('/mnt/c/repo/app') + } + }) + + it('translates a \\\\wsl.localhost UNC repoPath into its linux path', () => { + const outcome = resolveCustom( + { commandOverride: '/bin/agent', args: '{repoPath}' }, + { + platform: 'linux', + shell: 'posix', + executionHostId: 'wsl:Ubuntu' as AgentLaunchExecutionHostId, + variables: { repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo' } + } + ) + expect(argvOf(outcome)).toEqual(['/bin/agent', '/home/dev/repo']) + }) +}) + +describe('snapshot immutability', () => { + it('deep-freezes the snapshot and excludes prompt/process/env-control data', () => { + const outcome = resolveCustom({ commandOverride: '/bin/agent', args: 'x' }) + if (!outcome.ok) { + throw new Error('expected launch') + } + expect(Object.isFrozen(outcome.launch.snapshot)).toBe(true) + expect(Object.isFrozen(outcome.launch.snapshot.argv)).toBe(true) + expect(Object.keys(outcome.launch.snapshot)).toEqual([ + 'version', + 'requestedAgent', + 'baseAgent', + 'displayLabel', + 'mode', + 'argv', + 'agentEnv', + 'capturedEnvPolicy', + 'target' + ]) + }) +}) + +describe('two-stage stable-input digest', () => { + function resolveWithWorktree(worktreePath: string): ResolveAgentLaunchOutcome { + return resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + variables: { repoPath: '/repo', worktreePath } + }), + catalogOf({}), + settingsOf() + ) + } + + it('stays identical across worktree paths while the admission fingerprint changes', () => { + const first = resolveWithWorktree('/wt-a') + const second = resolveWithWorktree('/wt-b') + if (!first.ok || !second.ok) { + throw new Error('expected both launches to resolve') + } + // The path-inclusive admission guard differs because a variable value changed. + expect(first.launch.admissionGuard.fingerprint).not.toBe( + second.launch.admissionGuard.fingerprint + ) + // The config-only digest is stable, so pre-create pinning (path unknown) and + // post-create resolution (authoritative path) recheck cleanly. + expect(first.launch.admissionGuard.stableInputDigest).toBe( + second.launch.admissionGuard.stableInputDigest + ) + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch-context.ts b/src/main/agent-launch/resolve-agent-launch-context.ts new file mode 100644 index 00000000000..1b9b7ea6819 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-context.ts @@ -0,0 +1,139 @@ +// Per-decision launch context: turns a chosen launch mode into the concrete +// templates and admitted env the command assembler and result builder consume. +// Safe fallback deliberately carries stock catalog argv only — no overrides, +// default args, default env, or the deleted custom's data — so a deleted agent +// can never escalate into a permission-bypassing base configuration (I8). + +import type { BuiltInTuiAgent, GlobalSettings, TuiAgent } from '../../shared/types' +import { TUI_AGENT_CONFIG, type TuiAgentConfig } from '../../shared/tui-agent-config' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { + getTuiAgentDefaultArgs, + getTuiAgentDefaultEnv +} from '../../shared/tui-agent-launch-defaults' +import type { AgentCatalog } from '../../shared/agent-catalog-normalization' +import type { AgentLaunchNotice } from '../../shared/agent-launch-contract' +import type { LaunchDecision } from './resolve-agent-selection' +import { + admitBuiltInEnv, + admitCustomEnv, + type LaunchClientKind +} from './resolve-agent-env-admission' + +export type LaunchContext = { + mode: 'built-in' | 'custom' | 'safe-fallback' + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent + displayLabel: string + config: TuiAgentConfig + commandOverride?: string | null + prefixOverride?: string | null + argsTemplate: string + isCustomArgs: boolean + env: Record + envPolicy: 'full' | 'withheld' | 'none' + notices: AgentLaunchNotice[] + /** Fingerprint source for the normalized definition/replay policy. */ + definitionDigestSource: unknown +} + +/** Read the configured per-built-in default args, preserving the shipped YOLO + * fallback when the key was never touched (mirrors resolveTuiAgentLaunchArgs + * without importing the soon-private helper). */ +function builtInArgsTemplate(base: BuiltInTuiAgent, settings: GlobalSettings): string { + const configured = settings.agentDefaultArgs + if (configured && Object.prototype.hasOwnProperty.call(configured, base)) { + return configured[base] ?? '' + } + return getTuiAgentDefaultArgs(base) +} + +function builtInEnv(base: BuiltInTuiAgent, settings: GlobalSettings): Record { + const configured = settings.agentDefaultEnv + if (configured && Object.prototype.hasOwnProperty.call(configured, base)) { + return { ...configured[base] } + } + return getTuiAgentDefaultEnv(base) +} + +function labelForCustomId(agent: TuiAgent, base: BuiltInTuiAgent, catalog: AgentCatalog): string { + return ( + catalog.liveById.get(agent as never)?.label ?? + catalog.tombstonesById.get(agent as never)?.label ?? + TUI_AGENT_DISPLAY_NAMES[base] + ) +} + +export function buildLaunchContext( + decision: Extract, + catalog: AgentCatalog, + settings: GlobalSettings, + client: LaunchClientKind +): LaunchContext { + if (decision.launch === 'built-in') { + const base = decision.agent + const env = admitBuiltInEnv(builtInEnv(base, settings)) + return { + mode: 'built-in', + requestedAgent: base, + baseAgent: base, + displayLabel: TUI_AGENT_DISPLAY_NAMES[base], + config: TUI_AGENT_CONFIG[base], + prefixOverride: settings.agentCmdOverrides?.[base] ?? null, + argsTemplate: builtInArgsTemplate(base, settings), + isCustomArgs: false, + env: env.env, + envPolicy: env.policy, + notices: [], + definitionDigestSource: { + prefix: settings.agentCmdOverrides?.[base] ?? null, + args: builtInArgsTemplate(base, settings) + } + } + } + + if (decision.launch === 'custom') { + const definition = catalog.liveById.get(decision.agent as never) + const base = decision.base + const admission = admitCustomEnv(definition?.env ?? {}, client, definition?.syncEnv ?? false) + const notices: AgentLaunchNotice[] = admission.withheld + ? [{ code: 'env_withheld', label: definition?.label ?? TUI_AGENT_DISPLAY_NAMES[base] }] + : [] + return { + mode: 'custom', + requestedAgent: decision.agent, + baseAgent: base, + displayLabel: definition?.label ?? TUI_AGENT_DISPLAY_NAMES[base], + config: TUI_AGENT_CONFIG[base], + commandOverride: definition?.commandOverride ?? null, + argsTemplate: definition?.args ?? '', + isCustomArgs: true, + env: admission.env, + envPolicy: admission.policy, + notices, + definitionDigestSource: definition ?? null + } + } + + // safe-fallback: stock catalog argv only, stale custom id retained as requested. + const base = decision.base + return { + mode: 'safe-fallback', + requestedAgent: decision.requestedAgent, + baseAgent: base, + displayLabel: labelForCustomId(decision.requestedAgent, base, catalog), + config: TUI_AGENT_CONFIG[base], + argsTemplate: '', + isCustomArgs: false, + env: Object.create(null) as Record, + envPolicy: 'none', + notices: [ + { + code: decision.notice, + label: labelForCustomId(decision.requestedAgent, base, catalog), + baseAgent: base + } + ], + definitionDigestSource: { safeFallbackBase: base } + } +} diff --git a/src/main/agent-launch/resolve-agent-launch-lifecycle.test.ts b/src/main/agent-launch/resolve-agent-launch-lifecycle.test.ts new file mode 100644 index 00000000000..4fa54fe330a --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-lifecycle.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, it } from 'vitest' +import type { BuiltInTuiAgent, TuiAgent } from '../../shared/types' +import type { + AgentLaunchExecutionHostId, + AgentLaunchSnapshot, + ResolveAgentLaunchRequest +} from '../../shared/agent-launch-host-contract' +import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import { tuiAgentToAgentKind } from '../../shared/agent-kind' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf, + tombstone +} from './agent-launch-test-catalog' + +type Column = + | 'interactive-stored' + | 'live-selection' + | 'cli' + | 'unattended' + | 'resume-with-snapshot' + | 'resume-without-snapshot' + +type Expected = + | { launch: 'built-in' | 'custom' | 'safe-fallback'; notice?: string } + | { failure: string } + | { requestError: string } + +const COLUMNS: readonly Column[] = [ + 'interactive-stored', + 'live-selection', + 'cli', + 'unattended', + 'resume-with-snapshot', + 'resume-without-snapshot' +] + +function snapshotFor(agent: TuiAgent, base: BuiltInTuiAgent): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: agent, + baseAgent: base, + displayLabel: 'Snap', + mode: base === agent ? 'built-in' : 'custom', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function columnRequest( + column: Column, + agent: TuiAgent, + snapshot: AgentLaunchSnapshot +): ResolveAgentLaunchRequest { + const base = requestOf({ selection: { kind: 'agent', agent } }) + switch (column) { + case 'interactive-stored': + return { + ...base, + intent: { kind: 'interactive', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'default' } + } + case 'live-selection': + return { + ...base, + intent: { kind: 'interactive', client: 'desktop' }, + reference: { kind: 'live-selection' } + } + case 'cli': + return { + ...base, + intent: { kind: 'cli', command: 'worktree-create' }, + reference: { kind: 'direct' } + } + case 'unattended': + return { + ...base, + intent: { kind: 'automation', runId: 'r1' }, + reference: { kind: 'persisted', owner: 'automation' } + } + case 'resume-with-snapshot': + return { + ...base, + intent: { kind: 'resume', operation: 'resume', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'session' }, + persistedSnapshot: snapshot + } + case 'resume-without-snapshot': + return { + ...base, + intent: { kind: 'resume', operation: 'resume', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'session' } + } + } +} + +type RowSetup = { + catalog: ReturnType + agent: TuiAgent + base: BuiltInTuiAgent +} + +const CID = customId('claude', '00000000-0000-4000-8000-00000000aaaa') +const CODEX_CID = customId('codex', '00000000-0000-4000-8000-00000000bbbb') +const MISSING_ID = customId('claude', '00000000-0000-4000-8000-00000000cccc') + +function setupRow(row: string): RowSetup { + switch (row) { + case 'enabled-built-in': + return { catalog: catalogOf({}), agent: 'claude', base: 'claude' } + case 'disabled-built-in': + return { + catalog: catalogOf({ disabledTuiAgents: ['claude'] }), + agent: 'claude', + base: 'claude' + } + case 'enabled-custom': + return { + catalog: catalogOf({ customTuiAgents: [customAgent({ id: CID })] }), + agent: CID, + base: 'claude' + } + case 'repair-required': + return { + catalog: catalogOf({ customTuiAgents: [customAgent({ id: CID, args: '"unterminated' })] }), + agent: CID, + base: 'claude' + } + case 'disabled-custom': + return { + catalog: catalogOf({ + customTuiAgents: [customAgent({ id: CID })], + disabledTuiAgents: [CID] + }), + agent: CID, + base: 'claude' + } + case 'missing-with-tombstone': + return { + catalog: catalogOf({ deletedCustomTuiAgents: [tombstone({ id: CID })] }), + agent: CID, + base: 'claude' + } + case 'missing-no-tombstone': + return { catalog: catalogOf({}), agent: MISSING_ID, base: 'claude' } + case 'base-disabled': + return { + catalog: catalogOf({ + customTuiAgents: [customAgent({ id: CODEX_CID, baseAgent: 'codex' })], + disabledTuiAgents: ['codex'] + }), + agent: CODEX_CID, + base: 'codex' + } + default: + throw new Error(`unknown row ${row}`) + } +} + +const TABLE: Record> = { + 'enabled-built-in': { + 'interactive-stored': { launch: 'built-in' }, + 'live-selection': { launch: 'built-in' }, + cli: { launch: 'built-in' }, + unattended: { launch: 'built-in' }, + 'resume-with-snapshot': { launch: 'built-in' }, + 'resume-without-snapshot': { launch: 'built-in' } + }, + 'disabled-built-in': { + 'interactive-stored': { failure: 'base_agent_disabled' }, + 'live-selection': { failure: 'base_agent_disabled' }, + cli: { failure: 'base_agent_disabled' }, + unattended: { failure: 'base_agent_disabled' }, + 'resume-with-snapshot': { failure: 'base_agent_disabled' }, + 'resume-without-snapshot': { failure: 'base_agent_disabled' } + }, + 'enabled-custom': { + 'interactive-stored': { launch: 'custom' }, + 'live-selection': { launch: 'custom' }, + cli: { launch: 'custom' }, + unattended: { launch: 'custom' }, + 'resume-with-snapshot': { launch: 'custom' }, + 'resume-without-snapshot': { launch: 'custom' } + }, + 'repair-required': { + 'interactive-stored': { failure: 'agent_definition_needs_repair' }, + 'live-selection': { failure: 'agent_definition_needs_repair' }, + cli: { failure: 'agent_definition_needs_repair' }, + unattended: { failure: 'agent_definition_needs_repair' }, + 'resume-with-snapshot': { launch: 'custom' }, + 'resume-without-snapshot': { failure: 'agent_definition_needs_repair' } + }, + 'disabled-custom': { + 'interactive-stored': { launch: 'safe-fallback', notice: 'disabled_custom_fallback' }, + 'live-selection': { failure: 'custom_agent_disabled' }, + cli: { failure: 'custom_agent_disabled' }, + unattended: { failure: 'custom_agent_disabled' }, + 'resume-with-snapshot': { launch: 'custom' }, + 'resume-without-snapshot': { launch: 'safe-fallback', notice: 'disabled_custom_fallback' } + }, + 'missing-with-tombstone': { + 'interactive-stored': { launch: 'safe-fallback', notice: 'missing_custom_fallback' }, + 'live-selection': { requestError: 'untrusted_reference' }, + cli: { failure: 'unknown_agent' }, + unattended: { failure: 'unknown_agent' }, + 'resume-with-snapshot': { launch: 'custom' }, + 'resume-without-snapshot': { launch: 'safe-fallback', notice: 'missing_custom_fallback' } + }, + 'missing-no-tombstone': { + 'interactive-stored': { failure: 'unknown_agent' }, + 'live-selection': { failure: 'unknown_agent' }, + cli: { failure: 'unknown_agent' }, + unattended: { failure: 'unknown_agent' }, + 'resume-with-snapshot': { launch: 'custom' }, + 'resume-without-snapshot': { failure: 'unknown_agent' } + }, + 'base-disabled': { + 'interactive-stored': { failure: 'base_agent_disabled' }, + 'live-selection': { failure: 'base_agent_disabled' }, + cli: { failure: 'base_agent_disabled' }, + unattended: { failure: 'base_agent_disabled' }, + 'resume-with-snapshot': { failure: 'base_agent_disabled' }, + 'resume-without-snapshot': { failure: 'base_agent_disabled' } + } +} + +function assertOutcome(outcome: ResolveAgentLaunchOutcome, expected: Expected): void { + if ('requestError' in expected) { + expect(outcome).toEqual({ ok: false, requestError: { code: expected.requestError } }) + return + } + if ('failure' in expected) { + expect(outcome.ok).toBe(false) + if (!outcome.ok && 'failure' in outcome) { + expect(outcome.failure.code).toBe(expected.failure) + } else { + throw new Error('expected a launch failure, got a request error') + } + return + } + expect(outcome.ok).toBe(true) + if (!outcome.ok) { + throw new Error('expected a successful launch') + } + expect(outcome.launch.snapshot.mode).toBe(expected.launch) + // Oracle 17 mode semantics: used_custom_agent is exactly `mode === 'custom'` + // (a custom launch stays true even with empty args/env; safe-fallback and + // built-in are false), and the base kind is always the resolved base's kind — + // never `other`, since a valid launch always proved a base agent. + expect(outcome.launch.telemetry.usedCustomAgent).toBe(expected.launch === 'custom') + expect(outcome.launch.telemetry.agentKind).toBe( + tuiAgentToAgentKind(outcome.launch.baseAgent) + ) + expect(outcome.launch.telemetry.agentKind).not.toBe('other') + // Every launch cell in this table resolves to the stock `claude` argv. + expect([...outcome.launch.argv]).toEqual(['claude']) + const noticeCodes = outcome.launch.notices.map((notice) => notice.code) + if (expected.notice) { + expect(noticeCodes).toContain(expected.notice) + } +} + +describe('resolveAgentLaunch lifecycle truth table', () => { + for (const row of Object.keys(TABLE)) { + for (const column of COLUMNS) { + it(`${row} × ${column}`, () => { + const setup = setupRow(row) + const request = columnRequest(column, setup.agent, snapshotFor(setup.agent, setup.base)) + // Blank the built-in default args so every launch cell resolves to the + // bare `claude` argv; default-args behavior is covered in the assembly suite. + const outcome = resolveAgentLaunch( + request, + setup.catalog, + settingsOf({ agentDefaultArgs: { claude: '', codex: '' } }) + ) + assertOutcome(outcome, TABLE[row][column]) + }) + } + } +}) + +describe('default selection states', () => { + it('auto picks first detected effectively-enabled built-in', () => { + const catalog = catalogOf({ defaultTuiAgent: 'auto' }) + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'default' }, detectedStockBaseAgents: new Set(['codex']) }), + catalog, + settingsOf() + ) + expect(outcome.ok).toBe(true) + if (outcome.ok) { + expect(outcome.launch.baseAgent).toBe('codex') + expect(outcome.launch.admissionGuard.basis).toBe('default') + } + }) + + it('auto with unknown detection tries the first enabled built-in', () => { + const catalog = catalogOf({ defaultTuiAgent: 'auto' }) + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'default' }, detectedStockBaseAgents: null }), + catalog, + settingsOf() + ) + expect(outcome.ok && outcome.launch.baseAgent).toBe('claude') + }) + + it('auto with a concrete empty detection set fails no_agent_selected', () => { + const catalog = catalogOf({ defaultTuiAgent: 'auto' }) + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'default' }, detectedStockBaseAgents: new Set() }), + catalog, + settingsOf() + ) + expect(outcome).toEqual({ ok: false, failure: { code: 'no_agent_selected' } }) + }) + + it('blank default fails no_agent_selected', () => { + const catalog = catalogOf({ defaultTuiAgent: 'blank' }) + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'default' } }), + catalog, + settingsOf() + ) + expect(outcome).toEqual({ ok: false, failure: { code: 'no_agent_selected' } }) + }) + + it('null default fails no_agent_selected (repair attention)', () => { + const catalog = catalogOf({ defaultTuiAgent: null }) + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'default' } }), + catalog, + settingsOf() + ) + expect(outcome).toEqual({ ok: false, failure: { code: 'no_agent_selected' } }) + }) + + it('auto skips a disabled built-in in canonical order', () => { + const catalog = catalogOf({ defaultTuiAgent: 'auto', disabledTuiAgents: ['claude'] }) + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'default' }, detectedStockBaseAgents: null }), + catalog, + settingsOf() + ) + // claude-agent-teams is next in TUI_AGENT_AUTO_PICK_ORDER. + expect(outcome.ok && outcome.launch.baseAgent).toBe('claude-agent-teams') + }) + + it('explicit stored custom default uses safe fallback when disabled (attended)', () => { + const catalog = catalogOf({ + customTuiAgents: [customAgent({ id: CID })], + disabledTuiAgents: [CID], + defaultTuiAgent: CID + }) + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'default' }, + reference: { kind: 'persisted', owner: 'default' } + }), + catalog, + settingsOf() + ) + expect(outcome.ok).toBe(true) + if (outcome.ok) { + expect(outcome.launch.snapshot.mode).toBe('safe-fallback') + expect(outcome.launch.requestedAgent).toBe(CID) + expect(outcome.launch.baseAgent).toBe('claude') + } + }) +}) + +describe('resume snapshot validation', () => { + const goodTarget = { + platform: 'linux' as NodeJS.Platform, + executionHostId: 'local' as AgentLaunchExecutionHostId + } + + it('rejects an identity mismatch', () => { + const snapshot = snapshotFor('claude', 'claude') + const outcome = resolveAgentLaunch( + { + ...requestOf({ selection: { kind: 'agent', agent: 'codex' }, ...goodTarget }), + intent: { kind: 'resume', operation: 'resume', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'session' }, + persistedSnapshot: snapshot + }, + catalogOf({}), + settingsOf() + ) + expect(outcome.ok).toBe(false) + if (!outcome.ok && 'failure' in outcome) { + expect(outcome.failure).toEqual({ + code: 'invalid_launch_snapshot', + reason: 'identity_mismatch' + }) + } + }) + + it('rejects a target host mismatch', () => { + const snapshot = snapshotFor('claude', 'claude') + const outcome = resolveAgentLaunch( + { + ...requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + executionHostId: 'ssh:box' as AgentLaunchExecutionHostId + }), + intent: { kind: 'resume', operation: 'resume', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'session' }, + persistedSnapshot: snapshot + }, + catalogOf({}), + settingsOf() + ) + expect(outcome.ok).toBe(false) + if (!outcome.ok && 'failure' in outcome) { + expect(outcome.failure.code).toBe('invalid_launch_snapshot') + } + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch-mobile-env-replay.test.ts b/src/main/agent-launch/resolve-agent-launch-mobile-env-replay.test.ts new file mode 100644 index 00000000000..44b57e28c20 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-mobile-env-replay.test.ts @@ -0,0 +1,134 @@ +// Mobile/paired-web remove-only env replay (§581): a captured entry survives only +// when the current live definition still authorizes it (syncEnv on) with the same +// key (case-insensitive on Windows) and the same value. Removed keys, rotated +// values, opt-out, and deleted definitions withhold entries and raise env_withheld; +// current values are never substituted and new current entries never added. +import { describe, expect, it } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { resolveMobileRemoveOnlyReplayEnv } from './resolve-agent-launch-snapshot-comparison' +import type { LaunchTarget } from './resolve-agent-launch-result' +import { catalogOf, customAgent, customId, settingsOf } from './agent-launch-test-catalog' +import type { CustomTuiAgent } from '../../shared/types' + +const AGENT_ID = customId('claude') + +function targetOf(platform: NodeJS.Platform = 'linux'): LaunchTarget { + return { + platform, + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} + +function snapshotOf(agentEnv: Record): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: AGENT_ID, + baseAgent: 'claude', + displayLabel: 'My Agent', + mode: 'custom', + argv: ['claude'] as AgentLaunchSnapshot['argv'], + agentEnv, + capturedEnvPolicy: Object.keys(agentEnv).length > 0 ? 'full' : 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function replayEnv(args: { + agentEnv: Record + definition?: Partial + omitDefinition?: boolean + platform?: NodeJS.Platform +}) { + const custom = args.omitDefinition + ? [] + : [ + customAgent({ + id: AGENT_ID, + label: 'My Agent', + env: { API_KEY: 'v1' }, + syncEnv: true, + ...args.definition + }) + ] + return resolveMobileRemoveOnlyReplayEnv({ + snapshot: snapshotOf(args.agentEnv), + catalog: catalogOf({ customTuiAgents: custom }), + settings: settingsOf(), + target: targetOf(args.platform), + client: 'mobile', + variables: {}, + targetHomePath: '/home/dev' + }) +} + +describe('resolveMobileRemoveOnlyReplayEnv', () => { + it('keeps a captured entry the current definition still authorizes unchanged', () => { + expect(replayEnv({ agentEnv: { API_KEY: 'v1' } })).toEqual({ + env: { API_KEY: 'v1' }, + withheld: false + }) + }) + + it('withholds a captured entry whose current value was rotated', () => { + expect( + replayEnv({ agentEnv: { API_KEY: 'v1' }, definition: { env: { API_KEY: 'v2' } } }) + ).toEqual({ + env: {}, + withheld: true + }) + }) + + it('withholds every entry when the definition opted out of env sync', () => { + expect(replayEnv({ agentEnv: { API_KEY: 'v1' }, definition: { syncEnv: false } })).toEqual({ + env: {}, + withheld: true + }) + }) + + it('withholds only the removed key and keeps the still-authorized one', () => { + expect( + replayEnv({ agentEnv: { API_KEY: 'v1', EXTRA: 'e' }, definition: { env: { API_KEY: 'v1' } } }) + ).toEqual({ env: { API_KEY: 'v1' }, withheld: true }) + }) + + it('never adds a current entry that is absent from the snapshot', () => { + const result = replayEnv({ + agentEnv: { API_KEY: 'v1' }, + definition: { env: { API_KEY: 'v1', NEW_KEY: 'n' } } + }) + expect(result).toEqual({ env: { API_KEY: 'v1' }, withheld: false }) + expect(result.env.NEW_KEY).toBeUndefined() + }) + + it('withholds all entries when the definition is deleted/unavailable', () => { + expect(replayEnv({ agentEnv: { API_KEY: 'v1' }, omitDefinition: true })).toEqual({ + env: {}, + withheld: true + }) + }) + + it('matches keys case-insensitively on Windows, preserving captured key casing', () => { + expect( + replayEnv({ + agentEnv: { Api_Key: 'v1' }, + definition: { env: { API_KEY: 'v1' } }, + platform: 'win32' + }) + ).toEqual({ env: { Api_Key: 'v1' }, withheld: false }) + }) + + it('does not match keys case-insensitively off Windows', () => { + expect( + replayEnv({ agentEnv: { Api_Key: 'v1' }, definition: { env: { API_KEY: 'v1' } } }) + ).toEqual({ env: {}, withheld: true }) + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch-result.ts b/src/main/agent-launch/resolve-agent-launch-result.ts new file mode 100644 index 00000000000..188dc3dfc67 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-result.ts @@ -0,0 +1,175 @@ +// Result assembly: build the immutable ResolvedAgentLaunch — deep-frozen +// snapshot (resolved command prefix + user argv and admitted user env only, never +// prompt/process/Orca/Agent-Teams ephemeral data), resolved policy, telemetry, +// and the host-private admission fingerprint. + +import type { BuiltInTuiAgent, TuiAgent } from '../../shared/types' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import type { TuiAgentConfig } from '../../shared/tui-agent-config' +import { tuiAgentToAgentKind } from '../../shared/agent-kind' +import type { AgentLaunchIntentKind, AgentLaunchNotice } from '../../shared/agent-launch-contract' +import type { + AgentArgv, + AgentLaunchExecutionHostId, + ResolvedAgentLaunch +} from '../../shared/agent-launch-host-contract' +import { + computeAdmissionFingerprint, + digestObject, + type AdmissionFingerprintBasis +} from './agent-launch-fingerprint' +import type { LaunchClientKind } from './resolve-agent-env-admission' +import type { LaunchVariableName, LaunchVariableValues } from './resolve-agent-variables' + +export type LaunchTarget = { + platform: NodeJS.Platform + execution: 'native' | 'wsl' + shell: AgentStartupShell + isRemote: boolean + executionHostId: AgentLaunchExecutionHostId +} + +export type BuildResolvedLaunchParams = { + mode: 'built-in' | 'custom' | 'safe-fallback' + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent + displayLabel: string + argv: AgentArgv + /** Provider resume flags appended on a replay; excluded from the snapshot and + * durable launch config. */ + resumeArgvSuffix?: readonly string[] + env: Record + envPolicy: 'full' | 'withheld' | 'none' + referenced: readonly LaunchVariableName[] + values: LaunchVariableValues + notices: readonly AgentLaunchNotice[] + target: LaunchTarget + targetHomePath: string | null + intentKind: AgentLaunchIntentKind + client: LaunchClientKind + config: TuiAgentConfig + basis: AdmissionFingerprintBasis + definitionDigestSource: unknown + transportConfidential: boolean | null +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object') { + for (const key of Object.keys(value)) { + deepFreeze((value as Record)[key]) + } + Object.freeze(value) + } + return value +} + +function frozenEnv(source: Record): Readonly> { + const copy = Object.create(null) as Record + for (const key of Object.keys(source)) { + copy[key] = source[key] + } + return Object.freeze(copy) +} + +function dedupeNotices(notices: readonly AgentLaunchNotice[]): AgentLaunchNotice[] { + const seen = new Set() + const result: AgentLaunchNotice[] = [] + for (const notice of notices) { + if (!seen.has(notice.code)) { + seen.add(notice.code) + result.push(notice) + } + } + return result +} + +export function buildResolvedLaunch(params: BuildResolvedLaunchParams): ResolvedAgentLaunch { + const { config, target } = params + + const snapshot = deepFreeze({ + version: 1 as const, + requestedAgent: params.requestedAgent, + baseAgent: params.baseAgent, + displayLabel: params.displayLabel, + mode: params.mode, + argv: [...params.argv] as unknown as AgentArgv, + agentEnv: frozenEnv(params.env), + capturedEnvPolicy: params.envPolicy, + target: { + platform: target.platform, + execution: target.execution, + shell: target.shell, + isRemote: target.isRemote, + executionHostId: target.executionHostId + } + }) + + const fingerprintInputs = { + basis: params.basis, + requestedAgent: params.requestedAgent, + baseAgent: params.baseAgent, + mode: params.mode, + definitionDigest: digestObject(params.definitionDigestSource), + baseEnabled: true, + builtInCommandConfig: + params.mode === 'built-in' ? digestObject(params.definitionDigestSource) : '', + variableValues: params.values, + remoteEnvAuthorization: params.envPolicy, + managedProvider: '', + target: { + platform: target.platform, + execution: target.execution, + shell: target.shell, + isRemote: target.isRemote, + executionHostId: target.executionHostId, + homePath: params.targetHomePath + }, + transportConfidential: params.transportConfidential + } + const fingerprint = computeAdmissionFingerprint(fingerprintInputs) + // Config-only digest for U4's two-stage worktree recheck: identical to the + // admission fingerprint but with the volatile path variables excluded, so it + // stays stable between pre-create identity pinning (worktree path not yet + // authoritative) and post-create final resolution. Path availability is + // rechecked separately at final resolution, never folded into this digest. + const stableInputDigest = computeAdmissionFingerprint({ + ...fingerprintInputs, + variableValues: { repoPath: null, worktreePath: null } + }) + + return { + requestedAgent: params.requestedAgent, + baseAgent: params.baseAgent, + displayLabel: params.displayLabel, + argv: snapshot.argv, + ...(params.resumeArgvSuffix ? { resumeArgvSuffix: params.resumeArgvSuffix } : {}), + agentEnv: snapshot.agentEnv, + variables: { + values: { repoPath: params.values.repoPath, worktreePath: params.values.worktreePath }, + referenced: [...params.referenced] + }, + snapshot, + policy: { + intent: params.intentKind, + mode: params.mode, + client: params.client, + isRemote: target.isRemote, + platform: target.platform, + promptInjectionMode: config.promptInjectionMode, + expectedProcess: config.expectedProcess, + ...(config.preflightTrust ? { preflightTrust: config.preflightTrust } : {}), + ...(config.draftPromptFlag ? { draftPromptFlag: config.draftPromptFlag } : {}), + ...(config.draftPromptEnvVar ? { draftPromptEnvVar: config.draftPromptEnvVar } : {}), + ...(config.draftPasteReadySignal + ? { draftPasteReadySignal: config.draftPasteReadySignal } + : {}), + env: params.envPolicy + }, + notices: dedupeNotices(params.notices), + telemetry: { + agentKind: tuiAgentToAgentKind(params.baseAgent), + usedCustomAgent: params.mode === 'custom' + }, + admissionGuard: { fingerprint, stableInputDigest, basis: params.basis } + } +} diff --git a/src/main/agent-launch/resolve-agent-launch-resume-argv.test.ts b/src/main/agent-launch/resolve-agent-launch-resume-argv.test.ts new file mode 100644 index 00000000000..4cacb95b5b3 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-resume-argv.test.ts @@ -0,0 +1,147 @@ +// U5: a resume/fork replay appends the provider resume flags to the immutable +// snapshot argv (from the record's session, never the snapshot), and builds the +// launch command from base + resume flags while the durable launch config keeps +// only the base command so a fresh relaunch never re-resumes a stale session. +import { describe, expect, it } from 'vitest' +import type { + AgentLaunchSnapshot, + ResolvedAgentLaunch +} from '../../shared/agent-launch-host-contract' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import { buildAgentStartupPlanFromResolvedLaunch } from '../../shared/resolved-agent-startup-plan' +import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import { catalogOf, requestOf, settingsOf } from './agent-launch-test-catalog' + +function snapshotOf(overrides: Partial = {}): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'darwin', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + }, + ...overrides + } +} + +function replay( + snapshot: AgentLaunchSnapshot, + resumeProviderSession: AgentProviderSessionMetadata +): ResolveAgentLaunchOutcome { + return resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: snapshot.requestedAgent }, + intent: { kind: 'resume', operation: 'resume', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'session' }, + platform: 'darwin', + shell: 'posix', + persistedSnapshot: snapshot, + resumeProviderSession + }), + catalogOf({}), + settingsOf() + ) +} + +function launchOf(outcome: ResolveAgentLaunchOutcome): ResolvedAgentLaunch { + if (!outcome.ok) { + throw new Error(`expected launch, got ${JSON.stringify(outcome)}`) + } + return outcome.launch +} + +describe('resume-argv replay', () => { + it('preserves custom Codex argv and appends the record resume id exactly once', () => { + const launch = launchOf( + replay( + snapshotOf({ + requestedAgent: 'custom-agent:codex:sol', + baseAgent: 'codex', + displayLabel: 'Codex Sol', + mode: 'custom', + argv: ['codex', '--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'] + }), + { key: 'session_id', id: 'record-resume-id' } + ) + ) + expect(launch.argv).toEqual([ + 'codex', + '--model', + 'gpt-5.6-Sol', + '-c', + 'model_reasoning_effort=medium' + ]) + expect(launch.resumeArgvSuffix).toEqual(['resume', 'record-resume-id']) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: '', + allowEmptyPromptLaunch: true + }) + expect(plan?.launchCommand).toContain("'--model' 'gpt-5.6-Sol'") + expect(plan?.launchCommand.split('record-resume-id')).toHaveLength(2) + }) + + it('appends the claude resume flags to the command but not the snapshot argv', () => { + const launch = launchOf(replay(snapshotOf(), { key: 'session_id', id: 'sess-9' })) + // The immutable snapshot argv is unchanged; the resume flags ride a suffix. + expect(launch.argv).toEqual(['claude']) + expect(launch.resumeArgvSuffix).toEqual(['--resume', 'sess-9']) + + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: '', + allowEmptyPromptLaunch: true + }) + expect(plan?.launchCommand).toContain('--resume') + expect(plan?.launchCommand).toContain('sess-9') + // The durable config keeps only the base command — no resume flags leak in. + expect(plan?.launchConfig.agentCommand).toContain('claude') + expect(plan?.launchConfig.agentCommand).not.toContain('--resume') + expect(plan?.launchConfig.agentCommand).not.toContain('sess-9') + }) + + it('uses the antigravity conversation flag for a conversation-keyed session', () => { + const snapshot = snapshotOf({ + requestedAgent: 'antigravity', + baseAgent: 'antigravity', + argv: ['agy'] + }) + const launch = launchOf(replay(snapshot, { key: 'conversation_id', id: 'conv-1' })) + expect(launch.resumeArgvSuffix).toEqual(['--conversation', 'conv-1']) + }) + + it('invalidates when the session key type does not match the base', () => { + // Claude resumes by session_id; a conversation_id session cannot form a valid + // resume command, so the replay fails closed rather than launching fresh. + const outcome = replay(snapshotOf(), { key: 'conversation_id', id: 'conv-1' }) + expect(outcome.ok).toBe(false) + expect(!outcome.ok && 'failure' in outcome && outcome.failure.code).toBe( + 'invalid_launch_snapshot' + ) + }) + + it('replays without a resume suffix when no provider session is supplied', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + intent: { kind: 'resume', operation: 'resume', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'session' }, + platform: 'darwin', + shell: 'posix', + persistedSnapshot: snapshotOf() + }), + catalogOf({}), + settingsOf() + ) + expect(launchOf(outcome).resumeArgvSuffix).toBeUndefined() + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch-snapshot-comparison.ts b/src/main/agent-launch/resolve-agent-launch-snapshot-comparison.ts new file mode 100644 index 00000000000..e3c8f992cbd --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-snapshot-comparison.ts @@ -0,0 +1,178 @@ +// Snapshot definition-drift comparison, evaluated inside the resolver's replay +// path (I15: pure, no I/O). Before replay the resolver checks whether the +// requested agent's CURRENT effective definition still matches the captured +// snapshot; if not, it emits `snapshot_definition_changed`. A disabled, deleted, +// tombstoned, repair-required, or base-disabled definition has no current +// effective config, so it counts as differing (the lifecycle table's +// disabled/missing replay rows always carry the notice). Desktop/host replay +// compares label + argv + full agent env (keys AND values); mobile/paired-web +// compares label + argv + captured-vs-current env policy state only, never env +// keys/values, so a value-only edit cannot leak through the comparison. + +import type { GlobalSettings } from '../../shared/types' +import type { AgentCatalog } from '../../shared/agent-catalog-normalization' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { AgentLaunchNotice } from '../../shared/agent-launch-contract' +import { classifyRequestedState } from './resolve-agent-selection' +import { buildLaunchContext } from './resolve-agent-launch-context' +import { assembleCommand } from './resolve-agent-command' +import { prepareVariableValues } from './resolve-agent-variables' +import type { LaunchClientKind } from './resolve-agent-env-admission' +import type { LaunchTarget } from './resolve-agent-launch-result' + +type CurrentEffectiveDefinition = { + displayLabel: string + argv: readonly string[] + env: Record + envPolicy: 'full' | 'withheld' | 'none' +} + +export type SnapshotComparisonInput = { + snapshot: AgentLaunchSnapshot + catalog: AgentCatalog + settings: GlobalSettings + target: LaunchTarget + client: LaunchClientKind + variables: { repoPath?: string | null; worktreePath?: string | null } + targetHomePath: string | null +} + +/** Resolve the requested agent's current effective definition, or null when it + * is unavailable (disabled/deleted/tombstoned/repair/base-disabled, or its + * current args no longer assemble). Assembled with the resume request's own + * worktree variables; resume is same-worktree, so path values match capture and + * any argv difference reflects a definition change, not a path move. */ +function resolveCurrentEffectiveDefinition( + input: SnapshotComparisonInput +): CurrentEffectiveDefinition | null { + const state = classifyRequestedState(input.snapshot.requestedAgent, input.catalog) + const decision = + state.state === 'enabled-built-in' + ? ({ launch: 'built-in', agent: state.base } as const) + : state.state === 'enabled-custom' + ? ({ launch: 'custom', agent: state.agent, base: state.base } as const) + : null + if (!decision) { + return null + } + const context = buildLaunchContext(decision, input.catalog, input.settings, input.client) + const command = assembleCommand({ + config: context.config, + platform: input.target.platform, + isRemote: input.target.isRemote, + shell: input.target.shell, + targetHomePath: input.targetHomePath, + commandOverride: context.commandOverride, + prefixOverride: context.prefixOverride, + argsTemplate: context.argsTemplate, + isCustomArgs: context.isCustomArgs, + envValues: Object.keys(context.env).map((key) => context.env[key]), + values: prepareVariableValues(input.variables, input.target.execution) + }) + if (!command.ok) { + return null + } + return { + displayLabel: context.displayLabel, + argv: command.argv, + env: context.env, + envPolicy: context.envPolicy + } +} + +function argvEquals(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false + } + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false + } + } + return true +} + +function agentEnvEquals(a: Readonly>, b: Record): boolean { + const aKeys = Object.keys(a) + if (aKeys.length !== Object.keys(b).length) { + return false + } + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) { + return false + } + } + return true +} + +function definitionMatchesSnapshot( + snapshot: AgentLaunchSnapshot, + current: CurrentEffectiveDefinition, + client: LaunchClientKind +): boolean { + if (snapshot.displayLabel !== current.displayLabel || !argvEquals(snapshot.argv, current.argv)) { + return false + } + // Mobile/paired-web must never see env keys/values in the comparison, so it + // considers only the captured-vs-current env policy state; a value-only edit is + // handled by the remove-only replay + env_withheld path, not this notice. + if (client === 'mobile' || client === 'paired-web') { + return snapshot.capturedEnvPolicy === current.envPolicy + } + return agentEnvEquals(snapshot.agentEnv, current.env) +} + +export type MobileReplayEnvResult = { env: Record; withheld: boolean } + +/** Mobile/paired-web remove-only env replay (§581): keep a captured entry only + * when the CURRENT live definition still authorizes it — its admitted env + * (syncEnv on) still contains the same key (case-insensitive on Windows) with + * the SAME value. A removed key, a rotated value, an opt-out (syncEnv off → + * empty admitted env), or a missing/deleted definition withholds that entry. + * Withheld entries are never substituted with current values, and current + * entries absent from the snapshot are never added; the source snapshot is not + * mutated. Any withholding raises a single env_withheld notice at the call site. + * Desktop/host replay does NOT use this — it copies the captured env unchanged. */ +export function resolveMobileRemoveOnlyReplayEnv( + input: SnapshotComparisonInput +): MobileReplayEnvResult { + const current = resolveCurrentEffectiveDefinition(input) + const currentEnv = current?.env ?? {} + const caseInsensitive = input.target.platform === 'win32' + const currentLookup = caseInsensitive ? lowercaseKeyed(currentEnv) : currentEnv + const env: Record = {} + let withheld = false + for (const key of Object.keys(input.snapshot.agentEnv)) { + const lookupKey = caseInsensitive ? key.toLowerCase() : key + const currentValue = Object.prototype.hasOwnProperty.call(currentLookup, lookupKey) + ? currentLookup[lookupKey] + : undefined + if (currentValue !== undefined && currentValue === input.snapshot.agentEnv[key]) { + env[key] = input.snapshot.agentEnv[key] + } else { + withheld = true + } + } + return { env, withheld } +} + +function lowercaseKeyed(env: Record): Record { + const out: Record = {} + for (const key of Object.keys(env)) { + out[key.toLowerCase()] = env[key] + } + return out +} + +/** The `snapshot_definition_changed` notice when the current effective + * definition differs from the snapshot, or null when they match. */ +export function snapshotDefinitionChangedNotice( + input: SnapshotComparisonInput +): AgentLaunchNotice | null { + const current = resolveCurrentEffectiveDefinition(input) + const matches = + current !== null && definitionMatchesSnapshot(input.snapshot, current, input.client) + return matches + ? null + : { code: 'snapshot_definition_changed', label: input.snapshot.displayLabel } +} diff --git a/src/main/agent-launch/resolve-agent-launch-snapshot-target.test.ts b/src/main/agent-launch/resolve-agent-launch-snapshot-target.test.ts new file mode 100644 index 00000000000..511bab8f967 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-snapshot-target.test.ts @@ -0,0 +1,154 @@ +// Snapshot replay target-match rules (U5): a shell change replays when the +// snapshot's structured argv re-encodes losslessly in the new shell, while +// platform/execution/remote/host-id must still match exactly. Only `cmd` is a +// lossy target (it cannot deliver % ! ^ "), so a shell change into cmd fails +// closed when any captured element carries one of those. +import { describe, expect, it } from 'vitest' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import { catalogOf, requestOf, settingsOf } from './agent-launch-test-catalog' + +const RESUME_DESKTOP = { kind: 'resume', operation: 'resume', client: 'desktop' } as const + +function snapshotOf(overrides: { + shell: AgentStartupShell + argv: readonly string[] + platform?: NodeJS.Platform + execution?: 'native' | 'wsl' + isRemote?: boolean + executionHostId?: AgentLaunchExecutionHostId +}): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: overrides.argv as AgentLaunchSnapshot['argv'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: overrides.platform ?? 'win32', + execution: overrides.execution ?? 'native', + shell: overrides.shell, + isRemote: overrides.isRemote ?? false, + executionHostId: overrides.executionHostId ?? 'local' + } + } +} + +function replay( + snapshot: AgentLaunchSnapshot, + target: { + shell: AgentStartupShell + platform?: NodeJS.Platform + isRemote?: boolean + executionHostId?: AgentLaunchExecutionHostId + } +): ResolveAgentLaunchOutcome { + return resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: 'claude' }, + intent: RESUME_DESKTOP, + reference: { kind: 'persisted', owner: 'session' }, + platform: target.platform ?? 'win32', + shell: target.shell, + isRemote: target.isRemote ?? false, + executionHostId: target.executionHostId ?? 'local', + persistedSnapshot: snapshot + }), + catalogOf({}), + settingsOf() + ) +} + +function failureCode(outcome: ResolveAgentLaunchOutcome): string | null { + return !outcome.ok && 'failure' in outcome ? outcome.failure.code : null +} + +describe('snapshot replay shell/target matching', () => { + it('replays a shell change when every argv element re-encodes losslessly', () => { + const outcome = replay( + snapshotOf({ shell: 'powershell', argv: ['claude', '--flag', 'value'] }), + { + shell: 'cmd' + } + ) + expect(outcome.ok).toBe(true) + if (!outcome.ok) { + return + } + // Replay keeps the immutable snapshot argv; only the delivery shell changed. + expect(outcome.launch.argv).toEqual(['claude', '--flag', 'value']) + }) + + it('replays into a non-cmd shell even when captured argv carries cmd metacharacters', () => { + const outcome = replay(snapshotOf({ shell: 'cmd', argv: ['claude', '%x!^"'] }), { + shell: 'powershell' + }) + expect(outcome.ok).toBe(true) + }) + + it('fails closed when a shell change into cmd cannot encode an argv element', () => { + const outcome = replay(snapshotOf({ shell: 'powershell', argv: ['claude', '%USERPROFILE%'] }), { + shell: 'cmd' + }) + expect(outcome.ok).toBe(false) + expect(failureCode(outcome)).toBe('invalid_launch_snapshot') + }) + + it('still rejects a genuine target mismatch beyond shell (platform)', () => { + const outcome = replay(snapshotOf({ shell: 'posix', argv: ['claude'], platform: 'win32' }), { + shell: 'posix', + platform: 'linux' + }) + expect(outcome.ok).toBe(false) + expect(failureCode(outcome)).toBe('invalid_launch_snapshot') + }) + + it('rejects a WSL-distro change even when execution and platform still match', () => { + // Both are execution 'wsl' on linux, so only the distro-bearing host id differs. + const outcome = replay( + snapshotOf({ + shell: 'posix', + argv: ['claude'], + platform: 'linux', + execution: 'wsl', + executionHostId: 'wsl:ubuntu' + }), + { shell: 'posix', platform: 'linux', executionHostId: 'wsl:debian' } + ) + expect(outcome.ok).toBe(false) + expect(failureCode(outcome)).toBe('invalid_launch_snapshot') + }) + + it('rejects a remote<->local toggle when only isRemote differs', () => { + const outcome = replay( + snapshotOf({ shell: 'posix', argv: ['claude'], platform: 'linux', isRemote: false }), + { shell: 'posix', platform: 'linux', isRemote: true } + ) + expect(outcome.ok).toBe(false) + expect(failureCode(outcome)).toBe('invalid_launch_snapshot') + }) + + it('accepts a local-provider<->daemon replay: both resolve to the same local host id', () => { + // Local provider and the terminal daemon both carry executionHostId 'local', + // which is not command semantics, so the snapshot replays across them. + const outcome = replay( + snapshotOf({ + shell: 'posix', + argv: ['claude', '--flag'], + platform: 'linux', + executionHostId: 'local' + }), + { shell: 'posix', platform: 'linux', executionHostId: 'local' } + ) + expect(outcome.ok).toBe(true) + if (!outcome.ok) { + return + } + expect(outcome.launch.argv).toEqual(['claude', '--flag']) + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch-transport.test.ts b/src/main/agent-launch/resolve-agent-launch-transport.test.ts new file mode 100644 index 00000000000..fc79bc88a92 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-transport.test.ts @@ -0,0 +1,125 @@ +// Confidential-transport gating: env-bearing cross-host resolution requires an +// authenticated AND confidential channel; env-free launches may continue over a +// merely-authenticated one, and env is never silently dropped to make a launch +// succeed. +import { describe, expect, it } from 'vitest' +import { resolveAgentLaunch } from './resolve-agent-launch' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf +} from './agent-launch-test-catalog' + +const AGENT_ID = customId('codex') + +function envBearingCatalog() { + return catalogOf({ + customTuiAgents: [ + customAgent({ id: AGENT_ID, baseAgent: 'codex', label: 'Env Codex', env: { API_KEY: 'v' } }) + ] + }) +} + +function envFreeCatalog() { + return catalogOf({ + customTuiAgents: [customAgent({ id: AGENT_ID, baseAgent: 'codex', label: 'Plain Codex' })] + }) +} + +describe('secure env transport gating', () => { + it('fails env-bearing resolution when the channel is authenticated but not confidential', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: AGENT_ID }, + isRemote: true, + executionHostId: 'ssh:host-1', + transportConfidentialityAvailable: false + }), + envBearingCatalog(), + settingsOf() + ) + expect(outcome.ok).toBe(false) + if (outcome.ok || !('failure' in outcome)) { + return + } + expect(outcome.failure.code).toBe('secure_env_transport_unavailable') + // The failure never downgrades to an env-free launch or leaks env content. + expect(JSON.stringify(outcome)).not.toContain('API_KEY') + }) + + it('allows env-bearing resolution over a confidential channel', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: AGENT_ID }, + isRemote: true, + executionHostId: 'ssh:host-1', + transportConfidentialityAvailable: true + }), + envBearingCatalog(), + settingsOf() + ) + expect(outcome.ok).toBe(true) + if (!outcome.ok) { + return + } + expect(outcome.launch.agentEnv).toEqual({ API_KEY: 'v' }) + }) + + it('allows an env-free launch over a non-confidential channel', () => { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: AGENT_ID }, + isRemote: true, + executionHostId: 'ssh:host-1', + transportConfidentialityAvailable: false + }), + envFreeCatalog(), + settingsOf() + ) + expect(outcome.ok).toBe(true) + }) + + it('treats an undefined capability as same-host and does not gate', () => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: AGENT_ID } }), + envBearingCatalog(), + settingsOf() + ) + expect(outcome.ok).toBe(true) + }) + + it('gates snapshot replay carrying captured env the same way', () => { + const confidential = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: AGENT_ID }, + isRemote: true, + executionHostId: 'ssh:host-1', + transportConfidentialityAvailable: true + }), + envBearingCatalog(), + settingsOf() + ) + expect(confidential.ok).toBe(true) + if (!confidential.ok) { + return + } + const replay = resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: AGENT_ID }, + isRemote: true, + executionHostId: 'ssh:host-1', + transportConfidentialityAvailable: false, + persistedSnapshot: confidential.launch.snapshot + }), + envBearingCatalog(), + settingsOf() + ) + expect(replay.ok).toBe(false) + if (replay.ok || !('failure' in replay)) { + return + } + expect(replay.failure.code).toBe('secure_env_transport_unavailable') + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch.performance.test.ts b/src/main/agent-launch/resolve-agent-launch.performance.test.ts new file mode 100644 index 00000000000..3cb9d1e876a --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch.performance.test.ts @@ -0,0 +1,216 @@ +// Perf gate for the pure resolver (plan §1368-1371). CI asserts ALGORITHMIC/count +// invariants — O(1) launch lookup, no per-field linear rescan, one index per +// revision — never a wall-clock threshold (heterogeneous runners). Wall-clock p95 +// is emitted as PR evidence only; §1371 forbids a flaky timing assertion. + +import os from 'node:os' +import { performance } from 'node:perf_hooks' +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgent, CustomTuiAgentId } from '../../shared/types' +import type { AgentCatalog } from '../../shared/agent-catalog-normalization' +import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf +} from './agent-launch-test-catalog' + +type ScanCounts = { get: number; iterate: number; arrayScan: number } + +/** Short env entries (`KEY_i=value-i`); 64 stays well under the 16 KiB cap. */ +function makeEnv(entries: number): Record { + const env: Record = {} + for (let i = 0; i < entries; i += 1) { + env[`CUSTOM_ENV_KEY_${i}`] = `value-${i}` + } + return env +} + +/** A catalog of `size` live custom agents on one resumable base; the middle agent + * is the launch target and carries `envEntries` env entries. */ +function buildFixture( + size: number, + envEntries: number +): { catalog: AgentCatalog; selectedId: CustomTuiAgentId } { + const selectIndex = Math.floor(size / 2) + const agents: CustomTuiAgent[] = [] + let selectedId: CustomTuiAgentId | null = null + for (let i = 0; i < size; i += 1) { + const id = customId('claude') + if (i === selectIndex) { + selectedId = id + } + agents.push( + customAgent({ + id, + label: `Agent ${i}`, + args: '--model x', + env: i === selectIndex ? makeEnv(envEntries) : {} + }) + ) + } + if (!selectedId) { + throw new Error('fixture size must be positive') + } + return { catalog: catalogOf({ customTuiAgents: agents }), selectedId } +} + +const ARRAY_SCAN_PROPS = new Set([ + 'forEach', + 'map', + 'filter', + 'some', + 'every', + 'find', + 'findIndex', + 'reduce', + 'reduceRight', + 'flatMap', + 'indexOf', + 'includes', + 'slice' +]) + +/** Wrap the catalog's custom-agent index and list so any full scan or per-entry + * read is counted. A size-independent `get` count with zero iteration/array scan + * is the structural O(1) proof — robust across runners, unlike a timer. */ +function instrument(catalog: AgentCatalog): { catalog: AgentCatalog; counts: ScanCounts } { + const counts: ScanCounts = { get: 0, iterate: 0, arrayScan: 0 } + const liveById = new Proxy(catalog.liveById as Map, { + get(target, prop) { + if (prop === 'get') { + return (key: CustomTuiAgentId) => { + counts.get += 1 + return target.get(key) + } + } + if ( + prop === Symbol.iterator || + prop === 'forEach' || + prop === 'keys' || + prop === 'values' || + prop === 'entries' + ) { + counts.iterate += 1 + } + const value = Reflect.get(target, prop, target) + return typeof value === 'function' + ? (value as (...a: unknown[]) => unknown).bind(target) + : value + } + }) as unknown as AgentCatalog['liveById'] + const liveCustomAgents = new Proxy(catalog.liveCustomAgents as unknown[], { + get(target, prop) { + if (prop === Symbol.iterator || (typeof prop === 'string' && ARRAY_SCAN_PROPS.has(prop))) { + counts.arrayScan += 1 + } else if (typeof prop === 'string' && /^\d+$/.test(prop)) { + counts.arrayScan += 1 + } + const value = Reflect.get(target, prop, target) + return typeof value === 'function' + ? (value as (...a: unknown[]) => unknown).bind(target) + : value + } + }) as unknown as AgentCatalog['liveCustomAgents'] + return { catalog: { ...catalog, liveById, liveCustomAgents }, counts } +} + +function resolveOnce( + catalog: AgentCatalog, + selectedId: CustomTuiAgentId +): ResolveAgentLaunchOutcome { + return resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: selectedId } }), + catalog, + settingsOf() + ) +} + +describe('resolve-agent-launch performance budget', () => { + it('resolves a custom launch in O(1): lookup cost does not grow with catalog size', () => { + const small = buildFixture(2, 8) + const large = buildFixture(1000, 8) + const s = instrument(small.catalog) + const l = instrument(large.catalog) + + expect(resolveOnce(s.catalog, small.selectedId).ok).toBe(true) + expect(resolveOnce(l.catalog, large.selectedId).ok).toBe(true) + + // Never iterates the full custom-agent index or list at any size. + expect(s.counts.iterate).toBe(0) + expect(l.counts.iterate).toBe(0) + expect(s.counts.arrayScan).toBe(0) + expect(l.counts.arrayScan).toBe(0) + // Identical lookup cost at 2 and 1,000 agents proves O(1), not O(n). + expect(l.counts.get).toBe(s.counts.get) + expect(l.counts.get).toBeGreaterThan(0) + expect(l.counts.get).toBeLessThanOrEqual(20) + }) + + it('derives every field from one fetched entry: env size adds no catalog lookups', () => { + const lean = buildFixture(1000, 0) + const rich = buildFixture(1000, 64) + const li = instrument(lean.catalog) + const ri = instrument(rich.catalog) + + expect(resolveOnce(li.catalog, lean.selectedId).ok).toBe(true) + expect(resolveOnce(ri.catalog, rich.selectedId).ok).toBe(true) + + // 64 env entries vs 0 must not trigger extra command/args/env/label/base scans. + expect(ri.counts.get).toBe(li.counts.get) + expect(li.counts.arrayScan).toBe(0) + expect(ri.counts.arrayScan).toBe(0) + }) + + it('indexes the catalog once per revision and reuses it across resolutions', () => { + const { catalog, selectedId } = buildFixture(1000, 64) + // One normalize pass built the full index. + expect(catalog.liveById.size).toBe(1000) + expect(catalog.liveCustomAgents.length).toBe(1000) + + const first = resolveOnce(catalog, selectedId) + const second = resolveOnce(catalog, selectedId) + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + if (first.ok && second.ok) { + // Same catalog object, no re-normalize: identical resolved argv. + expect([...second.launch.argv]).toEqual([...first.launch.argv]) + } + }) + + it('records resolver throughput as PR evidence (never a CI wall-clock assertion)', () => { + const { catalog, selectedId } = buildFixture(1000, 64) + const request = requestOf({ selection: { kind: 'agent', agent: selectedId } }) + const settings = settingsOf() + + for (let i = 0; i < 2000; i += 1) { + resolveAgentLaunch(request, catalog, settings) + } + + const runs = process.env.ORCA_PERF_EVIDENCE ? 100 : 3 + const iterations = 10_000 + const perOpMs: number[] = [] + let okAll = true + for (let r = 0; r < runs; r += 1) { + const start = performance.now() + for (let i = 0; i < iterations; i += 1) { + if (!resolveAgentLaunch(request, catalog, settings).ok) { + okAll = false + } + } + perOpMs.push((performance.now() - start) / iterations) + } + perOpMs.sort((a, b) => a - b) + const p95 = perOpMs[Math.min(perOpMs.length - 1, Math.floor(perOpMs.length * 0.95))] + + // Evidence only — asserted nowhere (plan §1371: no wall-clock CI threshold). + console.log( + `[resolve-agent-launch.perf] node=${process.version} cpu=${os.cpus()[0]?.model ?? 'unknown'} ` + + `runs=${runs} iterationsPerRun=${iterations} p95PerResolutionMs=${p95.toFixed(5)} budgetMs=2` + ) + // Correctness gate: every measured resolution produced a launch. + expect(okAll).toBe(true) + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch.ts b/src/main/agent-launch/resolve-agent-launch.ts new file mode 100644 index 00000000000..061e234c30c --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch.ts @@ -0,0 +1,282 @@ +// The authoritative agent-launch resolver. Pure CPU: no fs, subprocess, network, +// or listeners (I15) — every host-produced input arrives in the request. Returns +// a fully resolved launch, a typed launch failure, or a request/control-plane +// error; never null, never a throw for expected lifecycle state. + +import type { GlobalSettings } from '../../shared/types' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { resolveStartupShell, CMD_UNENCODABLE_CHAR_RE } from '../../shared/tui-agent-startup-shell' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import { getAgentResumeArgv, isResumableTuiAgent } from '../../shared/agent-session-resume' +import type { AgentCatalog } from '../../shared/agent-catalog-normalization' +import type { + AgentLaunchResolution, + AgentLaunchSnapshot, + ResolveAgentLaunchRequest +} from '../../shared/agent-launch-host-contract' +import type { AgentLaunchNotice, AgentLaunchRequestError } from '../../shared/agent-launch-contract' +import { resolveSelection } from './resolve-agent-selection' +import { buildLaunchContext } from './resolve-agent-launch-context' +import { assembleCommand } from './resolve-agent-command' +import { prepareVariableValues } from './resolve-agent-variables' +import { clientOfIntent } from './resolve-agent-env-admission' +import { checkCommandTooLong, checkEnvPayloadTooLarge } from './agent-launch-payload-caps' +import { buildResolvedLaunch, type LaunchTarget } from './resolve-agent-launch-result' +import { + resolveMobileRemoveOnlyReplayEnv, + snapshotDefinitionChangedNotice +} from './resolve-agent-launch-snapshot-comparison' + +export type ResolveAgentLaunchOutcome = + | AgentLaunchResolution + | { ok: false; requestError: AgentLaunchRequestError } + +function hasUserPathOverride(env: Record): boolean { + return Object.keys(env).some((key) => key.toLowerCase() === 'path') +} + +function deriveTarget(request: ResolveAgentLaunchRequest): LaunchTarget { + return { + platform: request.platform, + execution: request.executionHostId.startsWith('wsl:') ? 'wsl' : 'native', + shell: resolveStartupShell(request.platform, request.shell), + isRemote: request.isRemote, + executionHostId: request.executionHostId + } +} + +/** A snapshot's structured argv re-quotes into any target shell except `cmd`, + * whose double-quoted form cannot faithfully deliver `% ! ^ "`. A shell change is + * therefore lossless unless a captured element is unencodable in the new shell. */ +function snapshotArgvEncodableInShell( + snapshot: AgentLaunchSnapshot, + shell: AgentStartupShell +): boolean { + if (shell !== 'cmd') { + return true + } + return !snapshot.argv.some((element) => CMD_UNENCODABLE_CHAR_RE.test(element)) +} + +function targetMatchesSnapshot(target: LaunchTarget, snapshot: AgentLaunchSnapshot): boolean { + // Shell equality is not required: the snapshot stores structured argv, so a + // shell change replays whenever every element re-encodes losslessly in the new + // shell. Local-provider↔daemon needs no special case — both resolve to the same + // executionHostId, which is not part of command semantics. + return ( + snapshot.target.platform === target.platform && + snapshot.target.execution === target.execution && + snapshot.target.isRemote === target.isRemote && + snapshot.target.executionHostId === target.executionHostId && + snapshotArgvEncodableInShell(snapshot, target.shell) + ) +} + +/** Replay a validated snapshot: argv/env come from the immutable snapshot, not + * the current definition. Fails closed on identity/target mismatch, and carries + * `snapshot_definition_changed` when the current effective definition drifted. */ +function replayFromSnapshot( + request: ResolveAgentLaunchRequest, + target: LaunchTarget, + catalog: AgentCatalog, + settings: GlobalSettings +): ResolveAgentLaunchOutcome { + const snapshot = request.persistedSnapshot + if (!snapshot) { + return { ok: false, failure: { code: 'invalid_launch_snapshot' } } + } + if (request.selection.kind === 'agent' && request.selection.agent !== snapshot.requestedAgent) { + return { ok: false, failure: { code: 'invalid_launch_snapshot', reason: 'identity_mismatch' } } + } + if (!targetMatchesSnapshot(target, snapshot)) { + return { ok: false, failure: { code: 'invalid_launch_snapshot' } } + } + const client = clientOfIntent(request.intent) + const comparisonInput = { + snapshot, + catalog, + settings, + target, + client, + variables: request.variables, + targetHomePath: request.targetHomePath ?? null + } + const env = Object.create(null) as Record + let envWithheld = false + if (client === 'mobile' || client === 'paired-web') { + // Remove-only replay (§581): withhold any captured entry the current live + // definition no longer authorizes (opt-out, removed key, rotated value, + // deleted def) — never substitute current values or add new ones. + const replayEnv = resolveMobileRemoveOnlyReplayEnv(comparisonInput) + Object.assign(env, replayEnv.env) + envWithheld = replayEnv.withheld + } else { + // Desktop/host replay copies the captured env unchanged. + for (const key of Object.keys(snapshot.agentEnv)) { + env[key] = snapshot.agentEnv[key] + } + } + // Confidential transport is a current gate that constrains replay: captured + // env never crosses hosts on an authenticated-but-plaintext channel. + if (Object.keys(env).length > 0 && request.transportConfidentialityAvailable === false) { + return { + ok: false, + failure: { + code: 'secure_env_transport_unavailable', + requestedAgent: snapshot.requestedAgent, + baseAgent: snapshot.baseAgent + } + } + } + // Append the provider resume flags to the replayed base argv, derived from the + // record's session (never persisted in the snapshot). A resume against a + // non-resumable base or a session whose key type does not match the base cannot + // produce a valid resume command, so it invalidates the replay. + let resumeArgvSuffix: readonly string[] | undefined + if (request.resumeProviderSession) { + if (!isResumableTuiAgent(snapshot.baseAgent)) { + return { ok: false, failure: { code: 'invalid_launch_snapshot' } } + } + const resumeArgv = getAgentResumeArgv(snapshot.baseAgent, request.resumeProviderSession) + if (!resumeArgv) { + return { ok: false, failure: { code: 'invalid_launch_snapshot' } } + } + resumeArgvSuffix = resumeArgv.slice(1) + } + const definitionNotice = snapshotDefinitionChangedNotice(comparisonInput) + const notices: AgentLaunchNotice[] = [] + if (definitionNotice) { + notices.push(definitionNotice) + } + if (envWithheld) { + // One generic notice for any mobile/paired remove-only withholding; it names + // no keys/values (secrets rule). + notices.push({ code: 'env_withheld', label: snapshot.displayLabel }) + } + return { + ok: true, + launch: buildResolvedLaunch({ + mode: snapshot.mode, + requestedAgent: snapshot.requestedAgent, + baseAgent: snapshot.baseAgent, + displayLabel: snapshot.displayLabel, + argv: [...snapshot.argv] as unknown as typeof snapshot.argv, + ...(resumeArgvSuffix ? { resumeArgvSuffix } : {}), + env, + envPolicy: Object.keys(env).length > 0 ? 'full' : 'none', + referenced: [], + values: { repoPath: null, worktreePath: null }, + notices, + target, + targetHomePath: request.targetHomePath ?? null, + intentKind: request.intent.kind, + client, + config: TUI_AGENT_CONFIG[snapshot.baseAgent], + basis: 'snapshot', + definitionDigestSource: { replaySnapshot: snapshot.argv }, + transportConfidential: request.transportConfidentialityAvailable ?? null + }) + } +} + +/** Resolve a launch request against the normalized catalog and current settings. */ +export function resolveAgentLaunch( + request: ResolveAgentLaunchRequest, + catalog: AgentCatalog, + settings: GlobalSettings +): ResolveAgentLaunchOutcome { + const selection = resolveSelection(request, catalog) + if (selection.kind === 'failure') { + return { ok: false, failure: selection.failure } + } + if (selection.kind === 'request-error') { + return { ok: false, requestError: selection.requestError } + } + + const target = deriveTarget(request) + + if (selection.decision.launch === 'replay-snapshot') { + return replayFromSnapshot(request, target, catalog, settings) + } + + const client = clientOfIntent(request.intent) + const context = buildLaunchContext(selection.decision, catalog, settings, client) + const values = prepareVariableValues(request.variables, target.execution) + + // Env may cross to a different terminal host only inside an authenticated, + // confidential channel; it never downgrades to plaintext or silently drops + // values. Env-free launches may continue over a non-confidential channel. + if (Object.keys(context.env).length > 0 && request.transportConfidentialityAvailable === false) { + return { + ok: false, + failure: { + code: 'secure_env_transport_unavailable', + requestedAgent: context.requestedAgent, + baseAgent: context.baseAgent + } + } + } + + const command = assembleCommand({ + config: context.config, + platform: request.platform, + isRemote: request.isRemote, + shell: target.shell, + targetHomePath: request.targetHomePath ?? null, + commandOverride: context.commandOverride, + prefixOverride: context.prefixOverride, + argsTemplate: context.argsTemplate, + isCustomArgs: context.isCustomArgs, + ...(request.perLaunchArgs !== undefined ? { perLaunchArgs: request.perLaunchArgs } : {}), + envValues: Object.keys(context.env).map((key) => context.env[key]), + values + }) + if (!command.ok) { + return { ok: false, failure: command.failure } + } + + // Stock-name detection gates only stock catalog argv with no accepted user PATH + // override; configured/custom prefixes and custom PATH env cannot be evaluated + // by name detection and proceed to preflight/spawn. + if ( + command.prefixSource === 'catalog' && + request.detectedStockBaseAgents !== null && + !request.detectedStockBaseAgents.has(context.baseAgent) && + !hasUserPathOverride(context.env) + ) { + return { ok: false, failure: { code: 'base_agent_unavailable', baseAgent: context.baseAgent } } + } + + const commandCap = checkCommandTooLong(command.argv, target.shell) + if (commandCap) { + return { ok: false, failure: commandCap } + } + const envCap = checkEnvPayloadTooLarge(command.argv, context.env, target) + if (envCap) { + return { ok: false, failure: envCap } + } + + return { + ok: true, + launch: buildResolvedLaunch({ + mode: context.mode, + requestedAgent: context.requestedAgent, + baseAgent: context.baseAgent, + displayLabel: context.displayLabel, + argv: command.argv, + env: context.env, + envPolicy: context.envPolicy, + referenced: command.referenced, + values, + notices: context.notices, + target, + targetHomePath: request.targetHomePath ?? null, + intentKind: request.intent.kind, + client, + config: context.config, + basis: selection.basis, + definitionDigestSource: context.definitionDigestSource, + transportConfidential: request.transportConfidentialityAvailable ?? null + }) + } +} diff --git a/src/main/agent-launch/resolve-agent-selection.ts b/src/main/agent-launch/resolve-agent-selection.ts new file mode 100644 index 00000000000..c9df172fc5f --- /dev/null +++ b/src/main/agent-launch/resolve-agent-selection.ts @@ -0,0 +1,263 @@ +// Agent selection and the lifecycle truth table. Given a request's selection, +// intent, and reference authority, this decides WHICH agent identity is launched +// and in what mode (built-in, custom, safe fallback, snapshot replay) or which +// typed failure/request-error is returned — the pure "who" of a launch, before +// any command/env assembly. + +import type { BuiltInTuiAgent, TuiAgent } from '../../shared/types' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import { isCustomTuiAgentId } from '../../shared/custom-tui-agent-identity' +import { TUI_AGENT_AUTO_PICK_ORDER } from '../../shared/tui-agent-selection' +import type { AgentCatalog } from '../../shared/agent-catalog-normalization' +import type { + AgentReferenceAuthority, + LaunchIntent, + ResolveAgentLaunchRequest +} from '../../shared/agent-launch-host-contract' +import type { + AgentLaunchFailure, + AgentLaunchRequestError +} from '../../shared/agent-launch-contract' +import type { AdmissionFingerprintBasis } from './agent-launch-fingerprint' + +/** Which lifecycle-table column applies, derived from intent + reference + + * snapshot presence. */ +export type LifecycleColumn = + | 'interactive-stored' + | 'live-selection' + | 'cli' + | 'unattended' + | 'resume-with-snapshot' + | 'resume-without-snapshot' + +export function classifyLifecycleColumn( + intent: LaunchIntent, + reference: AgentReferenceAuthority, + hasSnapshot: boolean +): LifecycleColumn { + if (intent.kind === 'resume') { + return hasSnapshot ? 'resume-with-snapshot' : 'resume-without-snapshot' + } + if (intent.kind === 'cli') { + return 'cli' + } + if ( + intent.kind === 'automation' || + intent.kind === 'background' || + intent.kind === 'orchestration' + ) { + return 'unattended' + } + // Interactive: only a host-proven persisted reference grants tombstone/fallback + // authority; a live picker or raw-RPC value never does. + return reference.kind === 'persisted' ? 'interactive-stored' : 'live-selection' +} + +/** The mode/notice a launch decision carries once an identity is chosen. */ +export type LaunchDecision = + | { launch: 'built-in'; agent: BuiltInTuiAgent } + | { launch: 'custom'; agent: TuiAgent; base: BuiltInTuiAgent } + | { + launch: 'safe-fallback' + requestedAgent: TuiAgent + base: BuiltInTuiAgent + notice: 'missing_custom_fallback' | 'disabled_custom_fallback' + } + | { launch: 'replay-snapshot'; agent: TuiAgent; base: BuiltInTuiAgent } + +export type SelectionOutcome = + | { kind: 'decision'; decision: LaunchDecision; basis: AdmissionFingerprintBasis } + | { kind: 'failure'; failure: AgentLaunchFailure } + | { kind: 'request-error'; requestError: AgentLaunchRequestError } + +/** Auto-pick: first effectively-enabled built-in in canonical order that is + * concretely detected. Unknown detection (null) skips the detection filter to + * preserve shipped behavior; a concrete empty set yields no agent. */ +function autoPickBuiltIn( + catalog: AgentCatalog, + detected: ReadonlySet | null +): BuiltInTuiAgent | null { + for (const agent of TUI_AGENT_AUTO_PICK_ORDER) { + if (catalog.disabledAgents.has(agent)) { + continue + } + if (detected !== null && !detected.has(agent)) { + continue + } + return agent + } + return null +} + +export type RequestedState = + | { state: 'enabled-built-in'; base: BuiltInTuiAgent } + | { state: 'disabled-built-in'; base: BuiltInTuiAgent } + | { state: 'enabled-custom'; agent: TuiAgent; base: BuiltInTuiAgent } + | { state: 'disabled-custom'; agent: TuiAgent; base: BuiltInTuiAgent } + | { state: 'repair-required'; agent: TuiAgent; base: BuiltInTuiAgent } + | { state: 'missing-with-tombstone'; agent: TuiAgent; base: BuiltInTuiAgent } + | { state: 'missing-no-tombstone'; agent: TuiAgent } + | { state: 'base-disabled'; agent: TuiAgent; base: BuiltInTuiAgent } + +export function classifyRequestedState(agent: TuiAgent, catalog: AgentCatalog): RequestedState { + if (isBuiltInTuiAgent(agent)) { + return catalog.disabledAgents.has(agent) + ? { state: 'disabled-built-in', base: agent } + : { state: 'enabled-built-in', base: agent } + } + if (!isCustomTuiAgentId(agent)) { + return { state: 'missing-no-tombstone', agent } + } + const live = catalog.liveById.get(agent) + if (live) { + const base = live.baseAgent + if (catalog.disabledAgents.has(base)) { + return { state: 'base-disabled', agent, base } + } + return catalog.disabledAgents.has(agent) + ? { state: 'disabled-custom', agent, base } + : { state: 'enabled-custom', agent, base } + } + const repair = catalog.repairRequiredById.get(agent) + if (repair && repair.baseAgent) { + const base = repair.baseAgent + if (catalog.disabledAgents.has(base)) { + return { state: 'base-disabled', agent, base } + } + return { state: 'repair-required', agent, base } + } + const tombstone = catalog.tombstonesById.get(agent) + if (tombstone) { + const base = tombstone.baseAgent + if (catalog.disabledAgents.has(base)) { + return { state: 'base-disabled', agent, base } + } + return { state: 'missing-with-tombstone', agent, base } + } + return { state: 'missing-no-tombstone', agent } +} + +function evaluateLifecycle( + requested: RequestedState, + column: LifecycleColumn, + basis: AdmissionFingerprintBasis +): SelectionOutcome { + const decide = (d: LaunchDecision): SelectionOutcome => ({ kind: 'decision', decision: d, basis }) + const fail = (failure: AgentLaunchFailure): SelectionOutcome => ({ kind: 'failure', failure }) + + // Base-disable precedence wins over custom-disabled/missing/repair everywhere. + if (requested.state === 'base-disabled' || requested.state === 'disabled-built-in') { + return fail({ code: 'base_agent_disabled', baseAgent: requested.base }) + } + + if (requested.state === 'enabled-built-in') { + return decide({ launch: 'built-in', agent: requested.base }) + } + + if (requested.state === 'repair-required') { + return fail({ code: 'agent_definition_needs_repair', requestedAgent: requested.agent }) + } + + if (requested.state === 'enabled-custom') { + return decide({ launch: 'custom', agent: requested.agent, base: requested.base }) + } + + if (requested.state === 'disabled-custom') { + if (column === 'interactive-stored' || column === 'resume-without-snapshot') { + return decide({ + launch: 'safe-fallback', + requestedAgent: requested.agent, + base: requested.base, + notice: 'disabled_custom_fallback' + }) + } + return fail({ code: 'custom_agent_disabled', requestedAgent: requested.agent }) + } + + if (requested.state === 'missing-with-tombstone') { + if (column === 'interactive-stored' || column === 'resume-without-snapshot') { + return decide({ + launch: 'safe-fallback', + requestedAgent: requested.agent, + base: requested.base, + notice: 'missing_custom_fallback' + }) + } + if (column === 'live-selection') { + // A live picker/raw-RPC value carries no fallback authority: reject the + // request without persisting any launch-attempt state. + return { kind: 'request-error', requestError: { code: 'untrusted_reference' } } + } + return fail({ code: 'unknown_agent', requestedAgent: requested.agent }) + } + + return fail({ code: 'unknown_agent', requestedAgent: requested.agent }) +} + +/** Resume-with-snapshot short-circuits the catalog-derived lifecycle: the + * snapshot is the identity/argv authority. Only a disabled base blocks replay + * here; full snapshot field validation happens in the command stage. */ +function resolveSnapshotReplay( + request: ResolveAgentLaunchRequest, + catalog: AgentCatalog +): SelectionOutcome { + const snapshot = request.persistedSnapshot + if (!snapshot) { + return { kind: 'failure', failure: { code: 'invalid_launch_snapshot' } } + } + if (catalog.disabledAgents.has(snapshot.baseAgent)) { + return { + kind: 'failure', + failure: { code: 'base_agent_disabled', baseAgent: snapshot.baseAgent } + } + } + return { + kind: 'decision', + decision: { + launch: 'replay-snapshot', + agent: snapshot.requestedAgent, + base: snapshot.baseAgent + }, + basis: 'snapshot' + } +} + +/** Resolve the selection to a launch decision or a typed failure/request-error. + * Does not assemble the command/env — only chooses the identity and mode. */ +export function resolveSelection( + request: ResolveAgentLaunchRequest, + catalog: AgentCatalog +): SelectionOutcome { + const column = classifyLifecycleColumn( + request.intent, + request.reference, + request.persistedSnapshot !== undefined + ) + + if (column === 'resume-with-snapshot') { + return resolveSnapshotReplay(request, catalog) + } + + if (request.selection.kind === 'default') { + const stored = catalog.defaultAgent + if (stored === 'auto') { + const picked = autoPickBuiltIn(catalog, request.detectedStockBaseAgents) + if (!picked) { + return { kind: 'failure', failure: { code: 'no_agent_selected' } } + } + return evaluateLifecycle(classifyRequestedState(picked, catalog), column, 'default') + } + // 'blank' and null are agent-required failures with the same code; null also + // implies repair attention, surfaced by the same no_agent_selected outcome. + if (stored === 'blank' || stored === null) { + return { kind: 'failure', failure: { code: 'no_agent_selected' } } + } + return evaluateLifecycle(classifyRequestedState(stored, catalog), column, 'default') + } + + return evaluateLifecycle( + classifyRequestedState(request.selection.agent, catalog), + column, + 'explicit' + ) +} diff --git a/src/main/agent-launch/resolve-agent-variables.ts b/src/main/agent-launch/resolve-agent-variables.ts new file mode 100644 index 00000000000..385ba88fd84 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-variables.ts @@ -0,0 +1,95 @@ +// Launch variable resolution: the {repoPath}/{worktreePath} scan, target-native +// (WSL) value translation, and per-string interpolation. Values are resolved to +// target-native form BEFORE substitution and a missing/empty referenced value +// fails the whole launch with no partial output. + +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toLinuxPath } from '../wsl' + +export type LaunchVariableName = 'repoPath' | 'worktreePath' + +/** Ordered so the first-missing report is deterministic. */ +export const LAUNCH_VARIABLE_ORDER: readonly LaunchVariableName[] = ['repoPath', 'worktreePath'] + +export type LaunchVariableValues = { + repoPath: string | null + worktreePath: string | null +} + +const VARIABLE_TOKENS: Record = { + repoPath: '{repoPath}', + worktreePath: '{worktreePath}' +} + +function toTargetNative( + value: string | null | undefined, + execution: 'native' | 'wsl' +): string | null { + if (value === null || value === undefined || value === '') { + return null + } + if (execution !== 'wsl') { + return value + } + // A Windows-form (drive or UNC) path must never enter a WSL argv. + const unc = parseWslUncPath(value) + if (unc) { + return unc.linuxPath + } + return toLinuxPath(value) +} + +/** Resolve the two supported variables to target-native values. Empty strings + * collapse to null so an empty-string substitution is treated as missing. */ +export function prepareVariableValues( + variables: { repoPath?: string | null; worktreePath?: string | null }, + execution: 'native' | 'wsl' +): LaunchVariableValues { + return { + repoPath: toTargetNative(variables.repoPath, execution), + worktreePath: toTargetNative(variables.worktreePath, execution) + } +} + +/** Whether `text` references the given variable token. */ +export function referencesVariable(text: string, name: LaunchVariableName): boolean { + return text.includes(VARIABLE_TOKENS[name]) +} + +/** Collect every variable referenced across the provided strings. */ +export function collectReferencedVariables(texts: readonly string[]): Set { + const referenced = new Set() + for (const text of texts) { + for (const name of LAUNCH_VARIABLE_ORDER) { + if (referencesVariable(text, name)) { + referenced.add(name) + } + } + } + return referenced +} + +/** The first referenced variable (in canonical order) whose value is missing, + * or null when every referenced variable has a value. */ +export function firstMissingVariable( + referenced: ReadonlySet, + values: LaunchVariableValues +): LaunchVariableName | null { + for (const name of LAUNCH_VARIABLE_ORDER) { + if (referenced.has(name) && values[name] === null) { + return name + } + } + return null +} + +/** Replace both supported tokens with their resolved values. Callers must have + * already verified referenced values are present; unknown brace text stays + * literal because only the two documented tokens are special. */ +export function interpolateVariables(text: string, values: LaunchVariableValues): string { + return text + .split(VARIABLE_TOKENS.repoPath) + .join(values.repoPath ?? '') + .split(VARIABLE_TOKENS.worktreePath) + .join(values.worktreePath ?? '') +} diff --git a/src/main/agent-launch/resolved-agent-startup-plan.test.ts b/src/main/agent-launch/resolved-agent-startup-plan.test.ts new file mode 100644 index 00000000000..3e7da56c623 --- /dev/null +++ b/src/main/agent-launch/resolved-agent-startup-plan.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest' +import { resolveAgentLaunch } from './resolve-agent-launch' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf +} from './agent-launch-test-catalog' +import { buildAgentStartupPlanFromResolvedLaunch } from '../../shared/resolved-agent-startup-plan' +import { STARTUP_COMMAND_TEXT_MAX_CHARS } from '../providers/windows-shell-args' +import type { ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract' + +function resolvedLaunch( + overrides: Parameters[0] extends infer _ ? Record : never = {} +): ResolvedAgentLaunch { + const outcome = resolveAgentLaunch( + requestOf({ + selection: { + kind: 'agent', + agent: (overrides.agent as ResolvedAgentLaunch['requestedAgent']) ?? 'codex' + }, + ...(overrides.request as object) + }), + (overrides.catalog as ReturnType) ?? catalogOf({}), + // Explicit empty args: an absent key falls back to the shipped YOLO + // defaults, which would clutter the exact-argv assertions below. + settingsOf({ + agentDefaultArgs: { + codex: '', + grok: '', + gemini: '', + opencode: '', + copilot: '', + autohand: '', + kiro: '', + claude: '', + pi: '', + hermes: '' + } + }) + ) + if (!outcome.ok || !('launch' in outcome)) { + throw new Error('fixture launch failed to resolve') + } + return outcome.launch +} + +describe('buildAgentStartupPlanFromResolvedLaunch', () => { + it('appends an argv prompt once and quotes each element for the target shell', () => { + const launch = resolvedLaunch() + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: 'fix the tests' }) + expect(plan?.launchCommand).toBe(`'codex' 'fix the tests'`) + expect(plan?.followupPrompt).toBeNull() + // The immutable snapshot argv is never extended by the prompt. + expect(launch.snapshot.argv).toEqual(['codex']) + expect(plan?.startupCommandDelivery).toBe('shell-ready') + }) + + it('keeps grok option termination before a flag-shaped prompt', () => { + const launch = resolvedLaunch({ agent: 'grok' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: '--version' }) + expect(plan?.launchCommand).toBe(`'grok' '--' '--version'`) + }) + + it('uses the flag modes from resolved policy, not a config re-read', () => { + const opencode = buildAgentStartupPlanFromResolvedLaunch({ + launch: resolvedLaunch({ agent: 'opencode' }), + prompt: 'p' + }) + expect(opencode?.launchCommand).toBe(`'opencode' '--prompt' 'p'`) + const gemini = buildAgentStartupPlanFromResolvedLaunch({ + launch: resolvedLaunch({ agent: 'gemini' }), + prompt: 'p' + }) + expect(gemini?.launchCommand).toBe(`'gemini' '--prompt-interactive' 'p'`) + const copilot = buildAgentStartupPlanFromResolvedLaunch({ + launch: resolvedLaunch({ agent: 'copilot' }), + prompt: 'p' + }) + expect(copilot?.launchCommand).toBe(`'copilot' '-i' 'p'`) + }) + + it('routes stdin-after-start agents through the followup writer with a bare TUI launch', () => { + const launch = resolvedLaunch({ agent: 'autohand' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: 'do the thing' }) + expect(plan?.launchCommand).toBe(`'autohand'`) + expect(plan?.followupPrompt).toBe('do the thing') + }) + + it('preserves fixed catalog subcommands in the quoted command', () => { + const launch = resolvedLaunch({ agent: 'kiro' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: '', + allowEmptyPromptLaunch: true + }) + expect(plan?.launchCommand).toBe(`'kiro-cli' 'chat' '--tui'`) + }) + + it('carries custom argv and admitted env without reparsing', () => { + const id = customId('codex') + const launch = resolvedLaunch({ + agent: id, + catalog: catalogOf({ + customTuiAgents: [ + customAgent({ + id, + baseAgent: 'codex', + label: 'Mine', + commandOverride: '/opt/my tools/codex', + args: '--model x', + env: { API_KEY: 'v' } + }) + ] + }) + }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: 'go' }) + expect(plan?.launchCommand).toBe(`'/opt/my tools/codex' '--model' 'x' 'go'`) + expect(plan?.env).toEqual({ API_KEY: 'v' }) + expect(plan?.launchConfig.agentEnv).toEqual({ API_KEY: 'v' }) + }) + + it('returns null for an empty prompt unless the surface allows a bare TUI', () => { + const launch = resolvedLaunch() + expect(buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: ' ' })).toBeNull() + expect( + buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: '', + allowEmptyPromptLaunch: true + })?.launchCommand + ).toBe(`'codex'`) + }) + + it('doubles smart quotes when quoting an argv prompt for a powershell target', () => { + const launch = resolvedLaunch({ + request: { platform: 'win32', shell: 'powershell', targetHomePath: 'C:\\Users\\me' } + }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: 'fix Bob’s tests' }) + expect(plan?.launchCommand).toBe(`& 'codex' 'fix Bob’’s tests'`) + }) + + describe('hermes native startup query', () => { + it('delivers the prompt via the startup-query env, never the paste writer', () => { + const launch = resolvedLaunch({ agent: 'hermes' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: 'audit the repo' }) + expect(plan?.launchCommand.startsWith(`sh -c '`)).toBe(true) + expect(plan?.launchCommand).toContain('ORCA_HERMES_STARTUP_QUERY') + expect(plan?.env).toEqual({ ORCA_HERMES_STARTUP_QUERY: 'audit the repo' }) + expect(plan?.followupPrompt).toBeNull() + expect(plan?.draftPrompt).toBeUndefined() + // The durable relaunch config stays a bare TUI launch without the + // query wrapper or transport env. + expect(plan?.launchConfig.agentCommand).toBe(`'hermes' '--tui'`) + expect(plan?.launchConfig.agentEnv).not.toHaveProperty('ORCA_HERMES_STARTUP_QUERY') + }) + + it('wraps the Windows query in an encoded powershell invocation', () => { + const launch = resolvedLaunch({ + agent: 'hermes', + request: { platform: 'win32', shell: 'powershell', targetHomePath: 'C:\\Users\\me' } + }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: 'audit the repo' }) + expect(plan?.launchCommand.startsWith('powershell.exe -NoProfile -EncodedCommand ')).toBe( + true + ) + expect(plan?.env).toEqual({ ORCA_HERMES_STARTUP_QUERY: 'audit the repo' }) + }) + + it('carries the query env and wrapper on an SSH remote launch', () => { + const launch = resolvedLaunch({ + agent: 'hermes', + request: { isRemote: true, targetHomePath: null } + }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: 'remote audit' }) + expect(plan?.launchCommand.startsWith(`sh -c '`)).toBe(true) + expect(plan?.env).toEqual({ ORCA_HERMES_STARTUP_QUERY: 'remote audit' }) + expect(plan?.followupPrompt).toBeNull() + }) + + it('falls back to the readiness paste when the query exceeds the env bound', () => { + const launch = resolvedLaunch({ agent: 'hermes' }) + const bigPrompt = 'x'.repeat(25_000) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: bigPrompt }) + expect(plan?.launchCommand).toBe(`'hermes' '--tui'`) + expect(plan?.followupPrompt).toBe(bigPrompt) + expect(plan?.env).toBeUndefined() + }) + + it('leaves draft delivery on the unsubmitted paste path', () => { + const launch = resolvedLaunch({ agent: 'hermes' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: 'draft me', + promptDelivery: 'draft' + }) + expect(plan?.launchCommand).toBe(`'hermes' '--tui'`) + expect(plan?.draftPrompt).toBe('draft me') + expect(plan?.env).toBeUndefined() + }) + }) + + describe('draft prompt delivery', () => { + it('appends the native draft flag inline and delivers nothing post-ready', () => { + const launch = resolvedLaunch({ agent: 'claude' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: 'wire the handler', + promptDelivery: 'draft' + }) + expect(plan?.launchCommand).toBe(`'claude' '--prefill' 'wire the handler'`) + expect(plan?.draftPrompt).toBeUndefined() + expect(plan?.followupPrompt).toBeNull() + }) + + it('sets the draft env var in spawn env only, never the durable snapshot', () => { + const launch = resolvedLaunch({ agent: 'pi' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: 'do it', + promptDelivery: 'draft' + }) + expect(plan?.launchCommand).toBe(`'pi'; unset ORCA_PI_PREFILL`) + expect(plan?.env).toEqual({ ORCA_PI_PREFILL: 'do it' }) + expect(plan?.launchConfig.agentEnv).not.toHaveProperty('ORCA_PI_PREFILL') + expect(plan?.draftPrompt).toBeUndefined() + }) + + it('returns the draft for post-ready paste when the agent has no native affordance', () => { + const launch = resolvedLaunch({ agent: 'codex' }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: 'draft me', + promptDelivery: 'draft' + }) + expect(plan?.launchCommand).toBe(`'codex'`) + expect(plan?.draftPrompt).toBe('draft me') + expect(plan?.followupPrompt).toBeNull() + }) + + it('falls back to post-ready paste with the FULL text for an oversized inline draft', () => { + const launch = resolvedLaunch({ agent: 'claude' }) + const bigDraft = 'x'.repeat(STARTUP_COMMAND_TEXT_MAX_CHARS + 100) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: bigDraft, + promptDelivery: 'draft', + maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS + }) + expect(plan?.launchCommand).toBe(`'claude'`) + expect(plan?.launchCommand).not.toContain('--prefill') + expect(plan?.draftPrompt).toBe(bigDraft) + }) + + it('leaves submit mode unchanged (native draft flag not applied)', () => { + const launch = resolvedLaunch({ agent: 'claude' }) + const submitted = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: 'ship it', + promptDelivery: 'submit' + }) + expect(submitted?.launchCommand).toBe(`'claude' 'ship it'`) + expect(submitted?.draftPrompt).toBeUndefined() + }) + }) +}) diff --git a/src/main/ai-vault/ai-vault-resume-locator.test.ts b/src/main/ai-vault/ai-vault-resume-locator.test.ts new file mode 100644 index 00000000000..86b6f484129 --- /dev/null +++ b/src/main/ai-vault/ai-vault-resume-locator.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { createAiVaultResumeLocator } from './ai-vault-resume-locator' + +describe('createAiVaultResumeLocator', () => { + const base = { + executionHostId: 'local' as const, + agent: 'codex' as const, + sessionId: 'session-1', + transcriptPath: '/home/me/.codex/sessions/session-1.jsonl', + platform: 'linux' as const + } + + it('is stable for the same canonical transcript and separates different paths', () => { + expect(createAiVaultResumeLocator(base)).toMatch(/^[a-f0-9]{64}$/) + expect( + createAiVaultResumeLocator({ + ...base, + transcriptPath: '/home/me/.codex/./sessions/a/../session-1.jsonl' + }) + ).toBe(createAiVaultResumeLocator(base)) + expect( + createAiVaultResumeLocator({ ...base, transcriptPath: '/other/session-1.jsonl' }) + ).not.toBe(createAiVaultResumeLocator(base)) + }) + + it('normalizes Windows separators and case while retaining host identity', () => { + const windows = { + ...base, + transcriptPath: 'C:\\Users\\Me\\session.jsonl', + platform: 'win32' as const + } + expect(createAiVaultResumeLocator(windows)).toBe( + createAiVaultResumeLocator({ ...windows, transcriptPath: 'c:/users/me/session.jsonl' }) + ) + expect(createAiVaultResumeLocator({ ...windows, executionHostId: 'ssh:box' })).not.toBe( + createAiVaultResumeLocator(windows) + ) + }) +}) diff --git a/src/main/ai-vault/ai-vault-resume-locator.ts b/src/main/ai-vault/ai-vault-resume-locator.ts new file mode 100644 index 00000000000..6343ec1a814 --- /dev/null +++ b/src/main/ai-vault/ai-vault-resume-locator.ts @@ -0,0 +1,39 @@ +import { createHash } from 'node:crypto' +import { posix, win32 } from 'node:path' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' + +const LOCATOR_VERSION = 'v1' + +function canonicalTranscriptPath(filePath: string, platform: NodeJS.Platform): string { + return platform === 'win32' + ? win32.normalize(filePath).replace(/\\/g, '/').toLowerCase() + : posix.normalize(filePath) +} + +function lengthDelimited(value: string): string { + return `${Buffer.byteLength(value, 'utf8')}:${value}` +} + +/** + * Builds the opaque selector echoed by Vault clients. The digest is only a + * collision-resistant fresh-scan locator; launch authority remains host-private. + */ +export function createAiVaultResumeLocator(args: { + executionHostId: ExecutionHostId + agent: AiVaultAgent + sessionId: string + transcriptPath: string + platform: NodeJS.Platform +}): string { + const tuple = [ + LOCATOR_VERSION, + args.executionHostId, + args.agent, + args.sessionId, + canonicalTranscriptPath(args.transcriptPath, args.platform) + ] + .map(lengthDelimited) + .join('') + return createHash('sha256').update(tuple, 'utf8').digest('hex') +} diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 922a6af96d7..03213cf80dd 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -17,6 +17,7 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({ const { getSavedRuntimeAiVaultHostInfos, prepareRuntimeAiVaultSessionResume, + resolveRuntimeAiVaultResumeDetails, scanRuntimeAiVaultSessions } = await import('./runtime-session-scanner') @@ -184,6 +185,34 @@ describe('runtime AI Vault session scanner', () => { }) ).rejects.toThrow('Invalid aiVault.prepareSessionResume response') }) + + it('forwards the on-demand resume details query and validates its result', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: { + status: 'ok', + args: ['--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'] + } + }) + const entry = { + executionHostId: 'runtime:env-1' as const, + agent: 'codex' as const, + sessionId: 'session-1' + } + + await expect(resolveRuntimeAiVaultResumeDetails('/user-data', 'env-1', entry)).resolves.toEqual( + { + status: 'ok', + args: ['--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'] + } + ) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'aiVault.resumeDetails', + { entry } + ) + }) }) function result( diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index b876c029b45..cd69a876e31 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -18,6 +18,10 @@ import type { } from '../../shared/ai-vault-session-title' import { parseAiVaultListResult } from './session-list-result-validation' import { parseAiVaultSessionTitlesResult } from './session-title-result-validation' +import type { + AgentLaunchVaultResumeDetailsResult, + AgentLaunchVaultResumeEntry +} from '../../shared/agent-launch-spawn-request' export type RuntimeAiVaultHostInfo = { environmentId: string @@ -34,6 +38,10 @@ const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean(), substituteCodexHome: z.string().optional() }) +const aiVaultResumeDetailsResultSchema = z.union([ + z.object({ status: z.literal('ok'), args: z.array(z.string()) }), + z.object({ status: z.literal('unavailable') }) +]) export function getSavedRuntimeAiVaultHostInfos( userDataPath: string @@ -151,6 +159,33 @@ export async function prepareRuntimeAiVaultSessionResume( return parsed.data } +export async function resolveRuntimeAiVaultResumeDetails( + userDataPath: string, + environmentId: string, + entry: AgentLaunchVaultResumeEntry +): Promise { + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'aiVault.resumeDetails', + { + // Why: the runtime must re-derive its transcript path during the fresh + // scan; the desktop-only compatibility field is not remote authority. + entry: { + executionHostId: entry.executionHostId, + agent: entry.agent, + sessionId: entry.sessionId, + ...(entry.resumeLocator ? { resumeLocator: entry.resumeLocator } : {}) + } + } + ) + if (response.ok !== true) { + return { status: 'unavailable' } + } + const parsed = aiVaultResumeDetailsResultSchema.safeParse(response.result) + return parsed.success ? parsed.data : { status: 'unavailable' } +} + function withRuntimeExecutionHost( result: AiVaultListResult, executionHostId: `runtime:${string}` diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 4ba72fdd43b..71124e25a3a 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -47,6 +47,10 @@ const aiVaultSessionSchema = z.object({ id: z.string(), executionHostId: executionHostIdSchema, executionHostPlatform: nodePlatformSchema.nullable().optional(), + resumeLocator: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), agent: z.string().min(1), sessionId: z.string(), title: z.string(), diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index e0fb7e8a0cd..ddf6ba90f6c 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -6,6 +6,7 @@ import type { import type { ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' +import { createAiVaultResumeLocator } from './ai-vault-resume-locator' export function aiVaultScanIssueResult(args: { executionHostId?: ExecutionHostId @@ -48,7 +49,15 @@ export function restampAiVaultListResult( : { ...session, executionHostId, - id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`, + resumeLocator: createAiVaultResumeLocator({ + executionHostId, + agent: session.agent, + sessionId: session.sessionId, + transcriptPath: session.filePath, + // This restamp runs on the transcript-owning host. + platform: session.executionHostPlatform ?? process.platform + }) } ), issues: result.issues.map((issue) => ({ ...issue, executionHostId })), diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index caf08f4259d..7b4e5fd7882 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -12,6 +12,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import { createAiVaultResumeLocator } from './ai-vault-resume-locator' import { extractFullFirstUserPromptText, normalizeFullFirstUserPromptText, @@ -106,6 +107,13 @@ export function finalizeSession( ...(options.executionHostPlatform ? { executionHostPlatform: options.executionHostPlatform } : {}), + resumeLocator: createAiVaultResumeLocator({ + executionHostId, + agent: accumulator.agent, + sessionId, + transcriptPath: accumulator.filePath, + platform + }), agent: accumulator.agent, sessionId, title, diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 4b0deb4c68f..9dfe64c8742 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -18,6 +18,7 @@ import { } from './session-scanner-antigravity-history' import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { createAiVaultResumeLocator } from './ai-vault-resume-locator' import { ensureSessionParseCacheLoaded, scheduleSessionParseCachePersist @@ -292,7 +293,7 @@ async function parseSessionCandidate( session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) } return { - session: session ? withSessionExecutionHost(session, executionHostId) : null, + session: session ? withSessionExecutionHost(session, executionHostId, platform) : null, issue: null } } catch (err) { @@ -310,7 +311,8 @@ async function parseSessionCandidate( function withSessionExecutionHost( session: AiVaultSession, - executionHostId: ExecutionHostId + executionHostId: ExecutionHostId, + platform: NodeJS.Platform ): AiVaultSession { if (session.executionHostId === executionHostId) { return session @@ -318,7 +320,14 @@ function withSessionExecutionHost( return { ...session, executionHostId, - id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`, + resumeLocator: createAiVaultResumeLocator({ + executionHostId, + agent: session.agent, + sessionId: session.sessionId, + transcriptPath: session.filePath, + platform + }) } } diff --git a/src/main/automations/automation-agent-launch-classifier.ts b/src/main/automations/automation-agent-launch-classifier.ts new file mode 100644 index 00000000000..fc29fc35d4b --- /dev/null +++ b/src/main/automations/automation-agent-launch-classifier.ts @@ -0,0 +1,47 @@ +// The resolve-only agent-launch classification seam for automations (U6). Kept +// separate from service.ts so the dispatch orchestrator stays under the line +// budget and the host can back this seam with the shared resolver independently. + +import type { + Automation, + AutomationDispatchResult, + AutomationRun +} from '../../shared/automations-types' +import type { AgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { AutomationRunTargetResult } from './run-target-resolution' + +/** Resolve-only classification of an automation's agent identity against the + * current catalog/settings and its run target. Returns a PLAIN structured + * failure for a known bad launch (deleted/disabled custom agent, unbuildable + * command, …) or null when the launch would resolve. Never spawns anything — + * and never mints the persisted wrapper: the service stamps it at the single + * persist point (ledger #12). */ +export type AutomationAgentLaunchClassifier = ( + automation: Automation, + run: AutomationRun, + target: Extract +) => AgentLaunchFailure | null + +/** Run the resolve-only classifier and, on a known failure, build the + * dispatch_failed result (additive structured failure + a retained generic + * `error` string for old readers). Returns null when the launch would resolve, + * so the caller proceeds to dispatch. Spawns nothing — the caller persists the + * returned result. */ +export function classifyAutomationLaunchDispatchFailure( + classify: AutomationAgentLaunchClassifier | null, + automation: Automation, + run: AutomationRun, + target: Extract +): AutomationDispatchResult | null { + const failure = classify?.(automation, run, target) ?? null + if (!failure) { + return null + } + return { + runId: run.id, + status: 'dispatch_failed', + workspaceId: automation.workspaceId, + error: `The automation's agent could not be launched (${failure.code}).`, + agentLaunchFailure: failure + } +} diff --git a/src/main/automations/automation-launch-failure-stamp.test.ts b/src/main/automations/automation-launch-failure-stamp.test.ts new file mode 100644 index 00000000000..c17cdd09301 --- /dev/null +++ b/src/main/automations/automation-launch-failure-stamp.test.ts @@ -0,0 +1,138 @@ +// Ledger #12: the host is the single minting authority for an automation run's +// persisted launch-failure wrapper. These cover the three field states — absent +// (preserve), present-null (clear), present-value (mint) — and prove a +// client-supplied wrapper is re-minted so it can never win over the host. +import { describe, expect, it } from 'vitest' +import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import type { AutomationDispatchResult } from '../../shared/automations-types' +import { parsePersistedAgentLaunchFailure } from '../../shared/agent-launch-failure-schema' +import { + mintPersistedAutomationLaunchFailure, + stampAutomationDispatchLaunchFailure +} from './automation-launch-failure-stamp' + +// The only keys a persisted launch failure may carry. Any command/argv/env +// key-or-value, label, or path text would show up as a key outside this set. +const ALLOWED_FAILURE_KEYS = new Set([ + 'code', + 'requestedAgent', + 'baseAgent', + 'variable', + 'field', + 'shell', + 'reason', + 'version', + 'failureId', + 'intent', + 'occurredAt' +]) + +describe('stampAutomationDispatchLaunchFailure (ledger #12)', () => { + it('mints the persisted wrapper for a plain failure from the dispatch arm', () => { + const result: AutomationDispatchResult = { + runId: 'run-1', + status: 'dispatch_failed', + error: 'launch failed', + agentLaunchFailure: { + code: 'invalid_launch_snapshot', + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex' + } + } + + const stamped = stampAutomationDispatchLaunchFailure(result) + + expect(stamped.agentLaunchFailure).toMatchObject({ + code: 'invalid_launch_snapshot', + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex', + version: 1, + intent: 'automation' + }) + expect(stamped.agentLaunchFailure?.failureId).toBeTruthy() + expect(typeof stamped.agentLaunchFailure?.occurredAt).toBe('number') + }) + + it('re-mints a client-supplied wrapper so the host id always wins', () => { + // A wrapper the client tried to forge (Persisted is assignable to the plain + // wire field). The stamp must overwrite the id and time. + const clientWrapper: PersistedAgentLaunchFailure = { + code: 'invalid_launch_snapshot', + version: 1, + failureId: 'client-forged-id', + intent: 'automation', + occurredAt: 42 + } + const result: AutomationDispatchResult = { + runId: 'run-1', + status: 'dispatch_failed', + agentLaunchFailure: clientWrapper + } + + const stamped = stampAutomationDispatchLaunchFailure(result) + + expect(stamped.agentLaunchFailure?.failureId).not.toBe('client-forged-id') + expect(stamped.agentLaunchFailure?.occurredAt).not.toBe(42) + }) + + it('preserves the run failure when the field is absent', () => { + const result: AutomationDispatchResult = { runId: 'run-1', status: 'dispatched' } + + const stamped = stampAutomationDispatchLaunchFailure(result) + + expect('agentLaunchFailure' in stamped).toBe(false) + }) + + it('clears the failure when the field is present and null', () => { + const result: AutomationDispatchResult = { + runId: 'run-1', + status: 'completed', + agentLaunchFailure: null + } + + const stamped = stampAutomationDispatchLaunchFailure(result) + + expect('agentLaunchFailure' in stamped).toBe(true) + expect(stamped.agentLaunchFailure).toBeNull() + }) +}) + +// G6 secret-leak oracle for the automation owner record: the persisted wrapper +// round-trips through JSON with no command/argv/env/label/path text, normalizes +// back through the strict schema, and a request error or a secret-bearing blob +// fails normalization rather than persisting. +describe('automation launch-failure round trip (G6)', () => { + it('round-trips a minted failure with only whitelisted keys and no secret text', () => { + const minted = mintPersistedAutomationLaunchFailure({ + code: 'invalid_agent_env', + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex', + field: 'env' + }) + const roundTripped = JSON.parse(JSON.stringify(minted)) + // Exactly the whitelist — no argv/env/command/label/path key survives. + for (const key of Object.keys(roundTripped)) { + expect(ALLOWED_FAILURE_KEYS.has(key)).toBe(true) + } + // Normalization holds on the way back in. + expect(parsePersistedAgentLaunchFailure(roundTripped)).toEqual(minted) + }) + + it('rejects a stored blob carrying secret env/argv text on read', () => { + const minted = mintPersistedAutomationLaunchFailure({ code: 'spawn_failed' }) + expect(parsePersistedAgentLaunchFailure({ ...minted, agentEnv: { TOKEN: 'x' } })).toBeNull() + expect(parsePersistedAgentLaunchFailure({ ...minted, argv: ['--secret'] })).toBeNull() + }) + + it('a request error cannot parse as the persisted automation failure', () => { + expect( + parsePersistedAgentLaunchFailure({ + code: 'idempotency_conflict', + version: 1, + failureId: 'x', + intent: 'automation', + occurredAt: 1 + }) + ).toBeNull() + }) +}) diff --git a/src/main/automations/automation-launch-failure-stamp.ts b/src/main/automations/automation-launch-failure-stamp.ts new file mode 100644 index 00000000000..bb61fb872e1 --- /dev/null +++ b/src/main/automations/automation-launch-failure-stamp.ts @@ -0,0 +1,48 @@ +// The single host-side minting point for an automation run's persisted +// launch-failure wrapper (ledger #12). Both failure arms — the renderer/headless +// dispatch result and the resolve-only classifier gate — carry a PLAIN +// AgentLaunchFailure; this stamps the host-authoritative wrapper before the +// store persists it, so there is exactly one place that can forge the +// comparison-keyed failureId and the client never mints it. + +import { randomUUID } from 'node:crypto' +import type { + AgentLaunchFailure, + PersistedAgentLaunchFailure +} from '../../shared/agent-launch-contract' +import type { + AutomationDispatchResult, + AutomationRunPersistInput +} from '../../shared/automations-types' + +/** Stamp the plain wire failure on a dispatch result into the store's persist + * input. Absent failure field → preserve (the store leaves the run's current + * failure untouched); present null → clear; present value → mint a fresh + * wrapper. Always mints anew so a re-submitted or client-minted wrapper cannot + * win over the host. */ +export function stampAutomationDispatchLaunchFailure( + result: AutomationDispatchResult +): AutomationRunPersistInput { + if (!Object.hasOwn(result, 'agentLaunchFailure')) { + const { agentLaunchFailure: _wireFailure, ...rest } = result + return rest + } + const failure = result.agentLaunchFailure + return { + ...result, + agentLaunchFailure: failure ? mintPersistedAutomationLaunchFailure(failure) : null + } +} + +/** Mint the host-authoritative persisted wrapper for the automation path. */ +export function mintPersistedAutomationLaunchFailure( + failure: AgentLaunchFailure +): PersistedAgentLaunchFailure { + return { + ...failure, + version: 1, + failureId: randomUUID(), + intent: 'automation', + occurredAt: Date.now() + } +} diff --git a/src/main/automations/headless-dispatch-run.ts b/src/main/automations/headless-dispatch-run.ts new file mode 100644 index 00000000000..fa98817a1ae --- /dev/null +++ b/src/main/automations/headless-dispatch-run.ts @@ -0,0 +1,88 @@ +// The headless (no-renderer) automation dispatch flow, extracted from service.ts +// so the orchestrator stays under the line budget. Precheck → launch via the +// injected headless dispatcher → persist dispatched/dispatch_failed, wiring the +// launch's completion promise back through markDispatchResult. + +import type { Store } from '../persistence' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { AutomationDispatchResult } from '../../shared/automations-types' +import { + didAutomationPrecheckPass, + formatAutomationPrecheckFailure +} from '../../shared/automation-precheck' +import type { AutomationRunTargetResult } from './run-target-resolution' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import type { AutomationPrecheckResult } from '../../shared/automations-types' + +export type HeadlessAutomationDispatchDeps = { + store: Store + headlessDispatcher: HeadlessAutomationDispatcher + runPrecheck: (automationId: string, runId: string) => Promise + markDispatchResult: (result: AutomationDispatchResult) => Promise +} + +export async function runHeadlessAutomationDispatch( + deps: HeadlessAutomationDispatchDeps, + automation: Automation, + run: AutomationRun, + target: Extract +): Promise { + const precheckResult = + run.trigger === 'scheduled' && automation.precheck + ? await deps.runPrecheck(automation.id, run.id) + : null + if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { + return deps.store.updateAutomationRun({ + runId: run.id, + status: 'skipped_precheck', + workspaceId: automation.workspaceId, + precheckResult, + error: formatAutomationPrecheckFailure(precheckResult) + }) + } + try { + const launch = await deps.headlessDispatcher({ automation, run, target }) + const launchRunTarget = { + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + terminalPaneKey: launch.terminalPaneKey ?? null, + terminalPtyId: launch.terminalPtyId ?? null + } + const updated = deps.store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + ...launchRunTarget, + error: null + }) + if (launch.completion) { + void launch.completion + .then((completion) => + deps.markDispatchResult({ + runId: run.id, + status: completion.status, + ...launchRunTarget, + precheckResult, + outputSnapshot: completion.outputSnapshot ?? null, + error: completion.error ?? null + }) + ) + .catch((error) => + deps.markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + ...launchRunTarget, + error: error instanceof Error ? error.message : String(error) + }) + ) + } + return updated + } catch (error) { + return deps.store.updateAutomationRun({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: automation.workspaceId, + error: error instanceof Error ? error.message : String(error) + }) + } +} diff --git a/src/main/automations/service-agent-launch-gate.test.ts b/src/main/automations/service-agent-launch-gate.test.ts new file mode 100644 index 00000000000..d60b9198ede --- /dev/null +++ b/src/main/automations/service-agent-launch-gate.test.ts @@ -0,0 +1,154 @@ +// U6: the resolve-only agent-launch gate in AutomationService. A known launch +// failure must record dispatch_failed + the additive structured failure and +// spawn NO terminal (neither the renderer dispatch IPC nor the headless +// dispatcher runs), across both workspace modes. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../../shared/types' +import type { AgentLaunchFailure } from '../../shared/agent-launch-contract' +import { AutomationService } from './service' +import type { AutomationAgentLaunchClassifier } from './automation-agent-launch-classifier' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +const makeRepo = (overrides: Partial = {}): Repo => ({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1, + ...overrides +}) + +// The classifier returns a PLAIN failure (ledger #12); the service mints the +// persisted wrapper at its single persist point. +const FAILURE: AgentLaunchFailure = { + code: 'invalid_launch_snapshot', + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex' +} + +async function seedDueAutomation( + workspaceMode: 'existing' | 'new_per_run' +): Promise<{ store: Awaited>; automationId: string }> { + vi.setSystemTime(new Date('2026-05-13T08:59:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Morning check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode, + ...(workspaceMode === 'existing' ? { workspaceId: 'wt1' } : {}), + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + vi.setSystemTime(new Date('2026-05-13T09:01:00')) + return { store, automationId: automation.id } +} + +describe('AutomationService agent-launch gate (U6)', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-automation-gate-')) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('records dispatch_failed + structured failure and sends NO renderer dispatch', async () => { + const { store, automationId } = await seedDueAutomation('existing') + const send = vi.fn() + const classifyAgentLaunch: AutomationAgentLaunchClassifier = () => FAILURE + const service = new AutomationService(store, { tickMs: 60_000, classifyAgentLaunch }) + service.setWebContents({ isDestroyed: () => false, send } as never) + + service.start() + service.setRendererReady() + await vi.waitFor(() => + expect(store.listAutomationRuns(automationId)[0]?.status).toBe('dispatch_failed') + ) + service.stop() + + // Zero PTY: the renderer dispatch IPC was never sent. + expect(send).not.toHaveBeenCalledWith('automations:dispatchRequested', expect.anything()) + const run = store.listAutomationRuns(automationId)[0] + // The host stamped the persisted wrapper (ledger #12): plain failure in, + // host-minted failureId/version/intent/occurredAt out. + expect(run?.agentLaunchFailure).toMatchObject({ + code: 'invalid_launch_snapshot', + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex', + version: 1, + intent: 'automation' + }) + expect(run?.agentLaunchFailure?.failureId).toBeTruthy() + expect(typeof run?.agentLaunchFailure?.occurredAt).toBe('number') + // Additive: the generic error string is retained for old readers. + expect(run?.error).toContain('invalid_launch_snapshot') + }) + + it('gates the headless new_per_run path too — no headless dispatcher call', async () => { + const { store, automationId } = await seedDueAutomation('new_per_run') + const headlessDispatcher = vi.fn() + const classifyAgentLaunch: AutomationAgentLaunchClassifier = () => FAILURE + // No webContents / not renderer-ready → the headless path is chosen. + const service = new AutomationService(store, { + tickMs: 60_000, + headlessDispatcher, + classifyAgentLaunch + }) + + service.start() + await vi.waitFor(() => + expect(store.listAutomationRuns(automationId)[0]?.status).toBe('dispatch_failed') + ) + service.stop() + + expect(headlessDispatcher).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automationId)[0]?.agentLaunchFailure?.code).toBe( + 'invalid_launch_snapshot' + ) + }) + + it('lets a resolvable launch (null classification) dispatch normally', async () => { + const { store, automationId } = await seedDueAutomation('existing') + const send = vi.fn() + const classifyAgentLaunch: AutomationAgentLaunchClassifier = () => null + const service = new AutomationService(store, { tickMs: 60_000, classifyAgentLaunch }) + service.setWebContents({ isDestroyed: () => false, send } as never) + + service.start() + service.setRendererReady() + await vi.waitFor(() => + expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.any(Object)) + ) + service.stop() + + expect(store.listAutomationRuns(automationId)[0]?.status).toBe('dispatching') + expect(store.listAutomationRuns(automationId)[0]?.agentLaunchFailure ?? null).toBeNull() + }) +}) diff --git a/src/main/automations/service-forget-run.test.ts b/src/main/automations/service-forget-run.test.ts new file mode 100644 index 00000000000..e94a1384590 --- /dev/null +++ b/src/main/automations/service-forget-run.test.ts @@ -0,0 +1,125 @@ +// U6: owner-authorized Forget of an automation run stranded mid-flight (renderer +// died before markDispatchResult, or a headless completion promise hung). The +// run moves to dispatch_failed + agentLaunchForgottenAt, spawns/kills nothing, +// and is never re-dispatched — the only duplicate-safe escape from the state the +// plan deliberately keeps non-final. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../../shared/types' +import type { AutomationRunStatus } from '../../shared/automations-types' +import { AutomationService } from './service' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +const REPO: Repo = { id: 'r1', path: '/repo', displayName: 'test', badgeColor: '#fff', addedAt: 1 } + +async function seedRunInStatus( + status: AutomationRunStatus +): Promise<{ store: Awaited>; runId: string }> { + const store = await createStore() + store.addRepo(REPO) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run it', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00').getTime()) + store.updateAutomationRun({ + runId: run.id, + status, + workspaceId: automation.workspaceId, + error: null + }) + return { store, runId: run.id } +} + +describe('AutomationService.forgetAutomationRun (U6)', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-forget-run-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('forgets a run stranded in dispatching: dispatch_failed + forgottenAt, no spawn', async () => { + const { store, runId } = await seedRunInStatus('dispatching') + const send = vi.fn() + const headlessDispatcher = vi.fn() + const service = new AutomationService(store, { tickMs: 60_000, headlessDispatcher }) + service.setWebContents({ isDestroyed: () => false, send } as never) + + const forgotten = service.forgetAutomationRun(runId) + + expect(forgotten.status).toBe('dispatch_failed') + expect(typeof forgotten.agentLaunchForgottenAt).toBe('number') + // No spawn/kill: neither the renderer dispatch IPC nor the headless dispatcher ran. + expect(send).not.toHaveBeenCalled() + expect(headlessDispatcher).not.toHaveBeenCalled() + expect(store.listAutomationRuns()[0]?.status).toBe('dispatch_failed') + }) + + it('forgets a headless run stranded in dispatched too', async () => { + const { store, runId } = await seedRunInStatus('dispatched') + const service = new AutomationService(store, { tickMs: 60_000 }) + + const forgotten = service.forgetAutomationRun(runId) + + expect(forgotten.status).toBe('dispatch_failed') + expect(typeof forgotten.agentLaunchForgottenAt).toBe('number') + }) + + it('never re-dispatches a forgotten run — the second Forget is an idempotent no-op', async () => { + const { store, runId } = await seedRunInStatus('dispatching') + const service = new AutomationService(store, { tickMs: 60_000 }) + + const first = service.forgetAutomationRun(runId) + const second = service.forgetAutomationRun(runId) + + expect(second.status).toBe('dispatch_failed') + // The final run is not rewritten: the forgotten timestamp is stable. + expect(second.agentLaunchForgottenAt).toBe(first.agentLaunchForgottenAt) + }) + + it('is a no-op on an already-settled run (its terminal outcome stands)', async () => { + const { store, runId } = await seedRunInStatus('completed') + const service = new AutomationService(store, { tickMs: 60_000 }) + + const result = service.forgetAutomationRun(runId) + + expect(result.status).toBe('completed') + expect(result.agentLaunchForgottenAt ?? null).toBeNull() + }) + + it('throws for an unknown run id', async () => { + const store = await createStore() + const service = new AutomationService(store, { tickMs: 60_000 }) + + expect(() => service.forgetAutomationRun('nope')).toThrow() + }) +}) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 9ed9564e5e5..e17ca6b145f 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -8,28 +8,19 @@ import { type AutomationPrecheckResult, type AutomationRun } from '../../shared/automations-types' +import { + classifyAutomationLaunchDispatchFailure, + type AutomationAgentLaunchClassifier +} from './automation-agent-launch-classifier' +import { stampAutomationDispatchLaunchFailure } from './automation-launch-failure-stamp' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' import { runAutomationPrecheck } from './precheck-runner' import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' import { collectAutomationRunUsage } from './run-usage-collection' import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import { runHeadlessAutomationDispatch } from './headless-dispatch-run' import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens' -import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' -import { - AutomationRunCompletionWatcher, - type AutomationRunTerminalObserver -} from './run-completion-watcher' -import { createAutomationRunWriter, type AutomationRunWriter } from './automation-run-writer' -import { - describeScheduledRefusal, - recordRefusedAutomationRun, - NO_DISPATCH_HOST -} from './dispatch-refusal' -import type { - AutomationsChangedPayload, - PublishAutomationsChanged -} from '../../shared/runtime-client-events' const DEFAULT_TICK_MS = 60 * 1000 @@ -44,13 +35,7 @@ export class AutomationService { private readonly codexUsage: CodexUsageStore | null private readonly allowRemoteHostScheduling: boolean private readonly headlessDispatcher: HeadlessAutomationDispatcher | null - private readonly publish: PublishAutomationsChanged | null - private readonly runs: AutomationRunWriter - private readonly completionWatcher: AutomationRunCompletionWatcher | null - /** Installed by desktop IPC registration, where external probes live; null on - * runtime servers. Orca's own automation traffic parks queued external - * probes behind this lease, whichever transport carried it. */ - externalProbePriority: ((run: () => T) => T) | null = null + private readonly classifyAgentLaunch: AutomationAgentLaunchClassifier | null constructor( store: Store, @@ -60,8 +45,10 @@ export class AutomationService { codexUsage?: CodexUsageStore allowRemoteHostScheduling?: boolean headlessDispatcher?: HeadlessAutomationDispatcher - terminalObserver?: AutomationRunTerminalObserver - onAutomationsChanged?: PublishAutomationsChanged + // U6: resolve-only classification of the automation's agent identity. When + // it returns a failure the run records dispatch_failed + the structured + // failure (additive to the generic error) and NO terminal is spawned. + classifyAgentLaunch?: AutomationAgentLaunchClassifier } = {} ) { this.store = store @@ -70,22 +57,7 @@ export class AutomationService { this.codexUsage = opts.codexUsage ?? null this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false this.headlessDispatcher = opts.headlessDispatcher ?? null - this.publish = opts.onAutomationsChanged ?? null - this.runs = createAutomationRunWriter(store, this.publish) - this.completionWatcher = opts.terminalObserver - ? new AutomationRunCompletionWatcher({ - observer: opts.terminalObserver, - readRun: (automationId, runId) => - this.store.listAutomationRuns(automationId).find((entry) => entry.id === runId) ?? null, - markDispatchResult: (result) => this.markDispatchResult(result) - }) - : null - } - - /** CRUD callers publish through the service so every authority write lands on - * the same local + runtime client-event pair. */ - publishAutomationsChanged(payload: AutomationsChangedPayload = {}): void { - this.publish?.(payload) + this.classifyAgentLaunch = opts.classifyAgentLaunch ?? null } setWebContents(webContents: WebContents | null): void { @@ -95,9 +67,6 @@ export class AutomationService { setRendererReady(): void { this.rendererReady = true - // Why: the renderer publishes the desktop window graph, so only after it - // attaches can an unresolvable pane mean a lost terminal rather than "not yet". - this.completionWatcher?.markTerminalSurfaceReady() void this.evaluateDueRuns() } @@ -108,19 +77,14 @@ export class AutomationService { this.timer = setInterval(() => { void this.evaluateDueRuns() }, this.tickMs) - this.completionWatcher?.reconcileRetainedRuns(this.store.listAutomationRuns()) // Why: headless serve never gets a renderer-ready IPC, but due runs still // need the same startup catch-up pass desktop gets after renderer attach. if (this.rendererReady || this.headlessDispatcher) { - // Serve adopts its daemon PTYs and publishes its graph before start(), so - // its terminal surface is already as answerable as it will get. - this.completionWatcher?.markTerminalSurfaceReady() void this.evaluateDueRuns() } } stop(): void { - this.completionWatcher?.dispose() if (!this.timer) { return } @@ -133,21 +97,8 @@ export class AutomationService { if (!automation) { throw new Error('Automation not found.') } - const run = this.runs.createRun(automation, Date.now(), 'manual') - return await this.requestDispatch(automation, run, this.resolveTarget(automation)) - } - - /** The run-history row doc:94 pairs with the typed refusal an execute fence throws. */ - recordRefusedRun(automationId: string): void { - const automation = this.store.listAutomations().find((entry) => entry.id === automationId) - if (automation) { - recordRefusedAutomationRun({ - store: this.store, - runs: this.runs, - automation, - allowRemoteHostScheduling: this.allowRemoteHostScheduling - }) - } + const run = this.store.createAutomationRun(automation, Date.now(), 'manual') + return await this.requestDispatch(automation, run) } async runPrecheck(automationId: string, runId: string): Promise { @@ -162,7 +113,9 @@ export class AutomationService { if (run.trigger !== 'scheduled' || !automation.precheck) { return null } - const target = this.resolveTarget(automation) + const target = resolveAutomationRunTarget(this.store, automation, { + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) if (!target.ok) { return { command: automation.precheck.command, @@ -188,15 +141,11 @@ export class AutomationService { } async markDispatchResult(result: AutomationDispatchResult): Promise { - const run = this.runs.updateRun(result) + const run = this.store.updateAutomationRun(stampAutomationDispatchLaunchFailure(result)) clearAutomationDispatchTokens(run.automationId, run.id) if (!isFinalAutomationRunStatus(run.status)) { - if (run.status === 'dispatched') { - this.completionWatcher?.watch(run) - } return run } - this.completionWatcher?.forget(run.id) // Why: the renderer's mark-completed effect can re-fire for the same run // before refresh() flips its status snapshot off 'dispatched'. Re-running // collectRunUsage advances the attribution window and can rewrite an @@ -215,7 +164,7 @@ export class AutomationService { if (!this.store.listAutomationRuns(run.automationId).some((entry) => entry.id === run.id)) { return run } - return this.runs.updateRun({ + return this.store.updateAutomationRun({ runId: run.id, status: run.status, workspaceId: run.workspaceId, @@ -225,6 +174,33 @@ export class AutomationService { }) } + /** Owner-authorized Forget of a run stranded mid-flight — the renderer died + * before markDispatchResult, or a headless completion promise hung — leaving + * it in `dispatching`/`dispatched` with no settlement. The run moves to + * dispatch_failed + agentLaunchForgottenAt and is never retried: the plan + * deliberately keeps a mid-flight run non-final so retry/timeout loops cannot + * re-dispatch it, so this is the only duplicate-safe escape. Spawns and kills + * nothing; a no-op on an already-settled run (its terminal outcome stands). */ + forgetAutomationRun(runId: string): AutomationRun { + const run = this.store.listAutomationRuns().find((entry) => entry.id === runId) + if (!run) { + throw new Error('Automation run not found.') + } + if (isFinalAutomationRunStatus(run.status)) { + return run + } + // Clear the dispatch token so a late renderer/headless completion for this + // run is rejected instead of resurrecting the forgotten run. + clearAutomationDispatchTokens(run.automationId, run.id) + return this.store.updateAutomationRun({ + runId, + status: 'dispatch_failed', + workspaceId: run.workspaceId, + error: 'The automation run was forgotten while its launch state was unknown.', + agentLaunchForgottenAt: Date.now() + }) + } + private async evaluateDueRuns(): Promise { if (this.evaluating) { return @@ -249,11 +225,11 @@ export class AutomationService { this.store.advanceAutomationNextRun(automation.id, now) return } + const run = this.store.createAutomationRun(automation, scheduledFor) const graceMs = automation.missedRunGraceMinutes * 60 * 1000 if (now - scheduledFor > graceMs) { - const missed = this.runs.createRun(automation, scheduledFor) - this.runs.updateRun({ - runId: missed.id, + this.store.updateAutomationRun({ + runId: run.id, status: 'skipped_missed', workspaceId: automation.workspaceId, error: 'Orca was unavailable during the missed-run grace window.' @@ -262,70 +238,52 @@ export class AutomationService { return } - // Resolved before the run exists: a refusal repeats every occurrence, and a - // */5 automation would otherwise write ~288 identical rows a day — past - // retention, which would evict the automation's real history. - const target = this.resolveTarget(automation) - const refusal = describeScheduledRefusal({ target, canDispatch: this.canDispatch() }) - if (refusal && this.runs.repeatSkip(automation.id, refusal, scheduledFor)) { - this.store.advanceAutomationNextRun(automation.id, now) - return - } - - await this.requestDispatch(automation, this.runs.createRun(automation, scheduledFor), target) + await this.requestDispatch(automation, run) this.store.advanceAutomationNextRun(automation.id, now) } - private resolveTarget(automation: Automation): AutomationRunTargetResult { - return resolveAutomationRunTarget(this.store, automation, { - allowRemoteHostScheduling: this.allowRemoteHostScheduling - }) - } - - private canDispatchToRenderer(): boolean { - const webContents = this.webContents - return Boolean(webContents && !webContents.isDestroyed() && this.rendererReady) - } - - /** Headless serve counts: it launches runs with no window at all. */ - private canDispatch(): boolean { - return this.canDispatchToRenderer() || Boolean(this.headlessDispatcher) - } - private async requestDispatch( automation: Automation, - run: AutomationRun, - target: AutomationRunTargetResult + run: AutomationRun ): Promise { + const target = resolveAutomationRunTarget(this.store, automation, { + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) if (!target.ok) { - return this.runs.updateRun({ + return this.store.updateAutomationRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, error: target.error }) } - if (!this.canDispatchToRenderer()) { + // Resolve-only agent-identity gate BEFORE any dispatch path (renderer or + // headless) and BOTH workspace modes: a known launch failure records the + // structured failure additively and spawns NO terminal. + const gated = classifyAutomationLaunchDispatchFailure( + this.classifyAgentLaunch, + automation, + run, + target + ) + if (gated) { + // Same single stamping point as markDispatchResult: the classifier returns + // a PLAIN failure and the host mints the persisted wrapper here (ledger #12). + return this.store.updateAutomationRun(stampAutomationDispatchLaunchFailure(gated)) + } + const webContents = this.webContents + if (!webContents || webContents.isDestroyed() || !this.rendererReady) { if (this.headlessDispatcher) { - return await runHeadlessAutomationDispatch({ - automation, - run, - target, - dispatcher: this.headlessDispatcher, - runs: this.runs, - runPrecheck: () => this.runPrecheck(automation.id, run.id), - markDispatchResult: (result) => this.markDispatchResult(result), - watchRun: (dispatched) => this.completionWatcher?.watch(dispatched) - }) + return await this.requestHeadlessDispatch(automation, run, target) } - return this.runs.updateRun({ + return this.store.updateAutomationRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, - error: NO_DISPATCH_HOST + error: 'No Orca window was available to launch the automation.' }) } - const updated = this.runs.updateRun({ + const updated = this.store.updateAutomationRun({ runId: run.id, status: 'dispatching', workspaceId: automation.workspaceId, @@ -336,7 +294,25 @@ export class AutomationService { run: updated, dispatchToken: createAutomationDispatchToken(automation.id, updated.id) } - this.webContents?.send('automations:dispatchRequested', payload) + webContents.send('automations:dispatchRequested', payload) return updated } + + private async requestHeadlessDispatch( + automation: Automation, + run: AutomationRun, + target: Extract + ): Promise { + return runHeadlessAutomationDispatch( + { + store: this.store, + headlessDispatcher: this.headlessDispatcher!, + runPrecheck: (automationId, runId) => this.runPrecheck(automationId, runId), + markDispatchResult: (result) => this.markDispatchResult(result) + }, + automation, + run, + target + ) + } } diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 7de9b21926a..9313e109e5d 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -117,6 +117,7 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, launchAgent: context.attachOnly ? undefined : opts.launchAgent, ...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}), + ...(!context.attachOnly && opts.launchToken ? { launchToken: opts.launchToken } : {}), shellOverride: context.attachOnly ? undefined : opts.shellOverride, terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: context.attachOnly diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index b47b497fe43..ad23d21fd26 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -104,6 +104,9 @@ export class DaemonTerminalAdmission { startupCommandDelivery: payload.startupCommandDelivery, ...(attachOnly ? { attachOnly: true } : {}), ...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}), + ...(typeof payload.launchToken === 'string' && payload.launchToken.length > 0 + ? { launchToken: payload.launchToken } + : {}), shellOverride: payload.shellOverride, terminalWindowsWslDistro: payload.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation, diff --git a/src/main/daemon/session-launch-token.test.ts b/src/main/daemon/session-launch-token.test.ts new file mode 100644 index 00000000000..88226b68cde --- /dev/null +++ b/src/main/daemon/session-launch-token.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { Session, type SubprocessHandle } from './session' + +function mockSubprocess(): SubprocessHandle { + return { + pid: 123, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + dispose: vi.fn() + } as unknown as SubprocessHandle +} + +describe('Session launchToken persistence', () => { + it('persists the launch token on the record at creation', () => { + const session = new Session({ + sessionId: 's-1', + cols: 80, + rows: 24, + subprocess: mockSubprocess(), + shellReadySupported: false, + launchToken: 'tok-abc' + }) + expect(session.launchToken).toBe('tok-abc') + session.dispose() + }) + + it('defaults to null when no launch token is supplied', () => { + const session = new Session({ + sessionId: 's-2', + cols: 80, + rows: 24, + subprocess: mockSubprocess(), + shellReadySupported: false + }) + expect(session.launchToken).toBeNull() + session.dispose() + }) +}) diff --git a/src/main/daemon/session-options.ts b/src/main/daemon/session-options.ts index 1e2d38814d4..216659fcdb6 100644 --- a/src/main/daemon/session-options.ts +++ b/src/main/daemon/session-options.ts @@ -9,6 +9,8 @@ export type SessionOptions = { rows: number terminalHandle?: string launchAgent?: TuiAgent + /** Host admission launch token retained for crash reconciliation. */ + launchToken?: string subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b0f1dfa538d..da5eb2e649d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -1,13 +1,13 @@ import { isValidPtySize } from './daemon-pty-size' -import type { SessionOutputPlane, AttachedClient } from './session-output-plane' -import { createSessionOutputPipeline } from './session-output-pipeline' +import { SessionOutputPlane, type AttachedClient } from './session-output-plane' import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' -import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import { SessionTerminationController, IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from './session-termination-controller' +import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' +export type { SubprocessHandle } from './session-subprocess-handle' import type { SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' @@ -28,6 +28,7 @@ export class Session { readonly incarnationId = randomUUID() readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null + readonly launchToken: string | null readonly wslDistro: string | null private _state: SessionState = 'running' private _exitCode: number | null = null @@ -39,26 +40,22 @@ export class Session { private readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController private readonly startupIngress: PtyStartupIngress - private readonly recoveryBarrier: TerminalShellRecoveryBarrier constructor(opts: SessionOptions) { this.sessionId = opts.sessionId this.terminalHandle = opts.terminalHandle ?? null this.launchAgent = opts.launchAgent ?? null + this.launchToken = opts.launchToken ?? null this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit - const pipeline = createSessionOutputPipeline({ + this.output = new SessionOutputPlane({ cols: opts.cols, rows: opts.rows, scrollback: opts.scrollback, wslDistro: opts.wslDistro, - historySeedChunks: opts.historySeedChunks, - subprocess: this.subprocess, - isAlive: () => !this._disposed && this._state !== 'exited' + historySeedChunks: opts.historySeedChunks }) - this.output = pipeline.output - this.recoveryBarrier = pipeline.recoveryBarrier this.producerPause = new SessionProducerPause(this.subprocess) this.termination = new SessionTerminationController({ sessionId: this.sessionId, @@ -84,14 +81,10 @@ export class Session { ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), - onEmission: (emission) => this.recoveryBarrier.accept(emission) + onEmission: (emission) => this.output.emit(emission) }) this.shellReady.startPromptReadinessProbe() - this.subprocess.onData((data) => { - if (!this._disposed) { - this.shellReady.ingestSubprocessData(data) - } - }) + this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } @@ -160,7 +153,10 @@ export class Session { } resize(cols: number, rows: number): void { - if (this._state === 'exited' || this._disposed || !isValidPtySize(cols, rows)) { + if (this._state === 'exited' || this._disposed) { + return + } + if (!isValidPtySize(cols, rows)) { return } this.output.resize(cols, rows) @@ -260,26 +256,22 @@ export class Session { return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() } - confirmShellForeground(): Promise { - return this.recoveryBarrier.confirmOwnerSettled() - } - - async settleShellOwnershipConfirmation(): Promise { - await this.recoveryBarrier.awaitProofSettled() - // Why the fence: a snapshot at settle-resolution must not race the drained prompt's async parse. - await this.output.flushParsedWrites() - } - clearScrollback(): void { - this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) + if (this._disposed) { + return + } + this.output.clearScrollback() + this.subprocess.clear?.() + nudgePowerShellPromptRepaint({ + subprocess: this.subprocess, + isGatingWrites: this.shellReady.isGatingWrites, + isCursorOnEmptyPromptLine: () => this.output.isCursorOnEmptyPromptLine() + }) } prepareForFinalSnapshot(): string { const held = this.shellReady.releaseHeldBytes() this.startupIngress.snapshotBarrier() - // Why last: snapshotBarrier can emit held spans into the barrier, and a - // teardown checkpoint mid-episode must not lose the barrier's queued bytes. - this.recoveryBarrier.flushPending() return held } @@ -293,10 +285,6 @@ export class Session { this.shellReady.releaseDeviceAttributes() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() - // Why after drainAndClose (and before clearClients below): a dispose - // mid-episode must deliver the barrier's queued bytes — drained ingress - // included — while clients are attached and the emulator accepts writes. - this.recoveryBarrier.flushPending() const wasTerminating = this.termination.isTerminating && this._state !== 'exited' const clientsToNotify = wasTerminating ? this.output.snapshotClients() : [] if (wasTerminating) { @@ -314,7 +302,6 @@ export class Session { this.output.clearClients() this.shellReady.clearPendingWrites() - this.recoveryBarrier.dispose() this.output.disposeEmulator() for (const client of clientsToNotify) { @@ -354,6 +341,13 @@ export class Session { this.termination.disposeSubprocessHandle() } + private handleSubprocessData(data: string): void { + if (this._disposed) { + return + } + this.shellReady.ingestSubprocessData(data) + } + private handleSubprocessExit(code: number, cause?: TerminalExitCause): void { this.termination.markPhysicalExit() if (this._disposed) { @@ -364,11 +358,6 @@ export class Session { this.shellReady.disposePromptReadinessProbe() this.shellReady.releaseHeldBytes() this.startupIngress.drainAndClose() - // Why after drainAndClose: drained ingress bytes re-enter the barrier and can - // open a fresh episode; flushing here delivers them too. A shell exiting - // mid-proof must not strand the queued post-133;D prompt — those bytes belong - // to clients, records, and history before broadcastExit below. - this.recoveryBarrier.flushPending() this._exitCode = code this._state = 'exited' this.termination.clearTerminating() diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index aaaffaeb8e8..19b8640d826 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -22,6 +22,8 @@ export type CreateOrAttachOptions = { launchAgent?: TuiAgent /** Missing ownership is not permission to create during stable-pane adoption. */ attachOnly?: boolean + /** Host admission launch token persisted on the session record at creation. */ + launchToken?: string /** Explicit shell the renderer asked for, forwarded to the subprocess. */ shellOverride?: string terminalWindowsWslDistro?: string | null diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index e1c8a22e750..05cc9f8d48f 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -116,6 +116,8 @@ async function spawnAndPublishSession( rows: size.rows, terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, launchAgent: opts.launchAgent, + // Why: surviving daemon sessions must retain admission identity across main-process crashes. + ...(opts.launchToken ? { launchToken: opts.launchToken } : {}), subprocess, ownerBackend: resolvePtyOwnerBackend({ platform: process.platform, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index bffdc1d2ed5..31bed4bf0c6 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -73,6 +73,8 @@ export type CreateOrAttachRequest = { launchAgent?: TuiAgent /** Rejects an absent session instead of interpreting mount uncertainty as create permission. */ attachOnly?: boolean + /** Host admission launch token persisted with the daemon session record. */ + launchToken?: string /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). * The daemon forwards this to its subprocess spawner so each tab honors * the shell picked in the "+" menu or the persisted default-shell setting, diff --git a/src/main/ipc/agent-catalog.ts b/src/main/ipc/agent-catalog.ts new file mode 100644 index 00000000000..9255afe7646 --- /dev/null +++ b/src/main/ipc/agent-catalog.ts @@ -0,0 +1,96 @@ +// Local preload IPC for agent-catalog authoring: revision-checked mutations, +// env-value-free local summaries, the bounded single-row draft read, and the +// desktop-only reference summary. None of these are runtime RPC methods — the +// remote surface receives only the env-free revisioned snapshot. + +import { ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { CustomTuiAgentId } from '../../shared/types' +import type { AgentCatalogMutationRequest } from '../../shared/agent-catalog-snapshot' +import type { AgentReferenceMutationRequest } from '../../shared/agent-reference-snapshot' +import { isCustomTuiAgentId } from '../../shared/custom-tui-agents' +import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' +import { getOrCreateAgentCatalogService } from '../agent-launch/agent-catalog-service' + +export function registerAgentCatalogHandlers(store: Store): void { + const service = getOrCreateAgentCatalogService(store) + + ipcMain.handle('settings:agentCatalog:getLocal', () => { + return service.getLocalSnapshot() + }) + + ipcMain.handle('settings:mutateAgentCatalog', (_event, request: AgentCatalogMutationRequest) => { + if ( + !request || + typeof request !== 'object' || + typeof request.expectedRevision !== 'number' || + !request.mutation || + typeof request.mutation !== 'object' + ) { + return { ok: false, code: 'invalid_agent_field', revision: service.getRevision() } + } + return service.mutate(request) + }) + + ipcMain.handle( + 'settings:agentCatalog:getLocalDraft', + ( + _event, + args: { locator: { id?: unknown; repairToken?: unknown }; expectedRevision?: unknown } + ) => { + const expectedRevision = + typeof args?.expectedRevision === 'number' ? args.expectedRevision : -1 + const locator = args?.locator + if (locator && isCustomTuiAgentId(locator.id)) { + return service.getLocalDraft({ id: locator.id }, expectedRevision) + } + if (locator && typeof locator.repairToken === 'string') { + return service.getLocalDraft({ repairToken: locator.repairToken }, expectedRevision) + } + return { status: 'stale' } + } + ) + + ipcMain.handle('settings:agentCatalog:referenceSummary', (_event, args: { id?: unknown }) => { + if (!args || !isCustomTuiAgentId(args.id)) { + return [] + } + return service.getReferenceSummaries(args.id as CustomTuiAgentId) + }) + + ipcMain.handle('settings:agentCatalog:baseDisableImpact', (_event, args: { base?: unknown }) => { + // Only a built-in base can be disabled-as-base; anything else has no impact. + if (!args || !isBuiltInTuiAgent(args.base)) { + return { + savedReferences: { count: 0, atLeast: false }, + resumableSessions: { count: 0, atLeast: false } + } + } + return service.getBaseDisableImpact(args.base) + }) + + ipcMain.handle('settings:agentReferences:getLocal', () => { + return service.getLocalReferenceSnapshot() + }) + + ipcMain.handle( + 'settings:mutateAgentReferences', + (_event, request: AgentReferenceMutationRequest) => { + if ( + !request || + typeof request !== 'object' || + typeof request.expectedReferenceRevision !== 'number' || + !request.mutation || + typeof request.mutation !== 'object' + ) { + return { + ok: false, + code: 'invalid_reference_field', + referenceRevision: service.getReferenceRevision(), + catalogRevision: service.getRevision() + } + } + return service.mutateReferences(request) + } + ) +} diff --git a/src/main/ipc/ai-vault-resume-command.ts b/src/main/ipc/ai-vault-resume-command.ts new file mode 100644 index 00000000000..ccba2e68112 --- /dev/null +++ b/src/main/ipc/ai-vault-resume-command.ts @@ -0,0 +1,113 @@ +// Desktop host surface for AI Vault resume (U5): re-validate a client-echoed +// entry against the desktop's OWN fresh multi-host discovery, then either return +// the discovered session for a spawn (pty.ts) or assemble the copyable command +// string. Split out of ai-vault.ts so the scan orchestration and the resume +// surface stay independently sized. The discovery function is injected so this +// module never imports the scan orchestration (no cycle). + +import { ipcMain } from 'electron' +import { + findVaultResumeSession, + resolveRevalidatedVaultResumeDetails, + resolveVaultResumeCopyCommand, + type VaultResumeAssemblySettings, + type VaultResumeCopyResult, + type VaultResumeDetailsResult, + type VaultResumeSession +} from '../agent-launch/agent-launch-vault-resume' +import type { AgentLaunchVaultResumeEntry } from '../../shared/agent-launch-spawn-request' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import { parseExecutionHostId } from '../../shared/execution-host' +import { getHostAgentSessionRecordStore } from '../agent-launch/agent-session-record-store-host' + +// Why: force a fresh scan scoped to the entry's host so a deleted session cannot +// replay from stale cache; the high limit surfaces an older target past the +// default recency cap. Resume/copy are user-initiated, not a hot path. +const VAULT_RESUME_REVALIDATION_LIMIT = 2000 + +export type DiscoverAiVaultSessions = (args: AiVaultListArgs) => Promise +export type ResolveRuntimeAiVaultResumeDetails = ( + environmentId: string, + entry: AgentLaunchVaultResumeEntry +) => Promise + +async function discoverForEntry( + entry: AgentLaunchVaultResumeEntry, + discover: DiscoverAiVaultSessions +): Promise { + return discover({ + executionHostScope: entry.executionHostId, + limit: VAULT_RESUME_REVALIDATION_LIMIT, + force: true + }) +} + +// Re-validate a client-echoed entry against the desktop's OWN discovery, scoped +// to the entry's host (local/ssh/runtime) so it matches what the picker showed. +// Returns the host-discovered session (authoritative filePath et al.) or null. +export async function revalidateAiVaultResumeEntry( + entry: AgentLaunchVaultResumeEntry, + discover: DiscoverAiVaultSessions +): Promise { + const discovered = await discoverForEntry(entry, discover) + return findVaultResumeSession(entry, discovered.sessions) +} + +// Desktop 'copy' vault-resume: re-validate + assemble the copyable command. The +// command is a pure clipboard artifact; a session the host did not discover is an +// in-band invalid_launch_snapshot, never a substituted current command. +async function resolveAiVaultResumeCopyCommand( + entry: AgentLaunchVaultResumeEntry, + discover: DiscoverAiVaultSessions, + settings: VaultResumeAssemblySettings | undefined +): Promise { + const discovered = await discoverForEntry(entry, discover) + return resolveVaultResumeCopyCommand({ + entry, + sessions: discovered.sessions, + hostPlatform: process.platform, + settings + }) +} + +async function resolveAiVaultResumeDetails( + entry: AgentLaunchVaultResumeEntry, + discover: DiscoverAiVaultSessions, + resolveRuntimeDetails?: ResolveRuntimeAiVaultResumeDetails +): Promise { + const parsedHost = parseExecutionHostId(entry.executionHostId) + if (parsedHost?.kind === 'runtime') { + return resolveRuntimeDetails + ? resolveRuntimeDetails(parsedHost.environmentId, entry) + : { status: 'unavailable' } + } + const session = await revalidateAiVaultResumeEntry(entry, discover) + return session + ? resolveRevalidatedVaultResumeDetails({ + session, + sessionRecordStore: getHostAgentSessionRecordStore() + }) + : { status: 'unavailable' } +} + +// Register the host-owned copy-command IPC. The renderer echoes a discovered +// entry's identity and the host re-validates + assembles the string, so the +// client no longer builds the launch itself. +export function registerAiVaultResumeCommandHandler( + discover: DiscoverAiVaultSessions, + options?: { + getVaultResumeSettings?: () => VaultResumeAssemblySettings | undefined + resolveRuntimeAiVaultResumeDetails?: ResolveRuntimeAiVaultResumeDetails + } +): void { + ipcMain.handle( + 'aiVault:resumeCommand', + (_event, entry: AgentLaunchVaultResumeEntry): Promise => + resolveAiVaultResumeCopyCommand(entry, discover, options?.getVaultResumeSettings?.()) + ) + ipcMain.handle( + 'aiVault:resumeDetails', + (_event, entry: AgentLaunchVaultResumeEntry): Promise => + resolveAiVaultResumeDetails(entry, discover, options?.resolveRuntimeAiVaultResumeDetails) + ) +} diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 746f469dba7..284013bb771 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -57,7 +57,11 @@ import { resolveAiVaultSessionTitlesByHost, type RuntimeAiVaultSessionTitleResolver } from './ai-vault-session-title-routing' -import { projectStructuredAiVaultSessions } from '../ai-vault/structured-session-ownership' +import { + registerAiVaultResumeCommandHandler, + type ResolveRuntimeAiVaultResumeDetails +} from './ai-vault-resume-command' +import type { VaultResumeAssemblySettings } from '../agent-launch/agent-launch-vault-resume' const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 // Why: a remote home with many agent roots routinely needs seconds to walk, @@ -73,6 +77,10 @@ type AiVaultHandlerOptions = AiVaultSessionSources & getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver + resolveRuntimeAiVaultResumeDetails?: ResolveRuntimeAiVaultResumeDetails + // Host settings for AI Vault resume-command assembly (cmd overrides, default + // args/env, Windows shell). Absent in tests → assembly falls back to defaults. + getVaultResumeSettings?: () => VaultResumeAssemblySettings | undefined } let scanCoordinator = new AiVaultScanCoordinator() @@ -269,6 +277,10 @@ async function scanLocalAiVaultSessions( ) } +// The desktop's OWN multi-host discovery (local + ssh + runtime), exported so the +// resume surfaces re-validate against exactly what the picker showed. +export { listAiVaultSessions as discoverAiVaultSessionsAcrossHosts } + export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options // Why: configure the SAME shared cache module the runtime RPC method uses so @@ -283,9 +295,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo : undefined const controller = listCancellations.begin(event, requestToken) try { - await handlerOptions.ensureStructuredSessionOwnership?.() - const result = await listAiVaultSessions(args, { signal: controller?.signal }) - return projectStructuredAiVaultSessions(result, true) + return await listAiVaultSessions(args, { signal: controller?.signal }) } catch (error) { // Why: superseding a scan is normal control flow, but Electron logs every // rejected handler — report it as a result so the log stays truthful. @@ -320,7 +330,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo handleAiVaultGetFirstUserPrompt(args) ) registerAiVaultDeleteHandler(aiVaultDeleteDeps) - // macOS app activation skips DOM focus events, so emit the refresh signal here. + registerAiVaultResumeCommandHandler(listAiVaultSessions, options) + // DOM focus/visibility events don't fire in the renderer on macOS app + // activation, so refresh-on-refocus needs this main-process signal. app.on('browser-window-focus', (_event, window) => { if (!window.isDestroyed()) { window.webContents.send('aiVault:windowFocused') diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index af3614d0e57..90c68a326a3 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -127,6 +127,13 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ (_event, result: AutomationDispatchResult): Promise => service.markDispatchResult(result) ) + // U9 W-T3 (plan :498): owner Forget of a run stranded in launch_state_unknown — + // settles it dispatch_failed + agentLaunchForgottenAt, never retried, spawns/ + // kills nothing, and is a no-op on an already-final run. + ipcMain.handle( + 'automations:forgetRun', + (_event, args: { runId: string }): AutomationRun => service.forgetAutomationRun(args.runId) + ) ipcMain.handle( 'automations:snapshotWorkspaceName', (_event, args: { workspaceId: string; displayName: string }): number => diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch.ts b/src/main/ipc/pty/ipc/spawn-agent-launch.ts new file mode 100644 index 00000000000..9f817fbc91f --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-agent-launch.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { AgentLaunchSnapshot, LaunchIntent } from '../../../../shared/agent-launch-host-contract' +import type { + AgentLaunchSpawnOutcome, + AgentLaunchSpawnRequest +} from '../../../../shared/agent-launch-spawn-request' +import { resolveStartupShell } from '../../../../shared/tui-agent-startup-shell' +import type { TuiAgent } from '../../../../shared/types' +import { + describeSpawnExecutionHost, + deriveAgentLaunchHostState, + detectionUnavailable, + resolveLocalTargetHomePath +} from '../../../agent-launch/agent-launch-host-state' +import { resolveAgentLaunchSpawn } from '../../../agent-launch/agent-launch-spawn' +import { resolveResumeLaunchIngest } from '../../../agent-launch/agent-launch-resume-ingest' +import { resolveRevalidatedVaultResume } from '../../../agent-launch/agent-launch-vault-resume' +import { getHostAgentSessionRecordStore } from '../../../agent-launch/agent-session-record-store-host' +import { getHostAgentLaunchBoundary } from '../../../agent-launch/agent-launch-boundary-host' +import { getHostBackgroundAgentLaunchStore } from '../../../agent-launch/background-agent-launch-store-host' +import { mintAgentLaunchOperationId } from '../../../agent-launch/agent-launch-operation-store' +import { + beginBackgroundDeclarationLaunch, + settleBackgroundDeclarationResolution, + settleBackgroundDeclarationSpawn, + type BackgroundDeclarationDeps, + type BackgroundDeclarationLaunch +} from '../../../agent-launch/background-agent-launch-spawn-declaration' +import { revalidateAiVaultResumeEntry } from '../../ai-vault-resume-command' +import { discoverAiVaultSessionsAcrossHosts } from '../../ai-vault' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { PtyIpcSpawnState } from './spawn-state' + +export type AgentLaunchEarlyResult = { agentLaunch: AgentLaunchSpawnOutcome } + +export async function resolvePtyIpcAgentLaunch( + ctx: PtyIpcSpawnState +): Promise { + const args = ctx.args + if (ctx.preAdoptedStablePane || !args.agentLaunch || !ctx.deps.getSettings) { + return null + } + + const getLaunchSettings = ctx.deps.getSettings + const descriptor = describeSpawnExecutionHost({ + connectionId: args.connectionId, + cwd: ctx.cwd, + terminalWindowsShell: getLaunchSettings()?.terminalWindowsShell + }) + const hostState = await deriveAgentLaunchHostState( + { + getSettings: getLaunchSettings, + getCatalogRevision: () => getLaunchSettings()?.agentCatalogRevision ?? 1, + detectStockBaseAgents: detectionUnavailable, + resolveTargetHomePath: resolveLocalTargetHomePath + }, + descriptor, + { worktreePath: ctx.cwd ?? null, repoPath: null } + ) + let resumeRequest: AgentLaunchSpawnRequest | null = null + let resumeIntent: LaunchIntent = { kind: 'interactive', client: 'desktop' } + let resumePersistedSnapshot: AgentLaunchSnapshot | undefined + let resumeProviderSession: AgentProviderSessionMetadata | undefined + let backgroundDeclaration: BackgroundDeclarationLaunch | null = null + let backgroundDeclarationRequestedAgent: TuiAgent | null = null + const backgroundDeclarationDeps: BackgroundDeclarationDeps = { + createAttempt: (input) => getHostBackgroundAgentLaunchStore().create(input), + settleLaunched: (attemptId) => getHostBackgroundAgentLaunchStore().settleLaunched(attemptId), + settleFailed: (attemptId, failure) => + getHostBackgroundAgentLaunchStore().settleFailed(attemptId, failure), + rollback: (attemptId) => getHostBackgroundAgentLaunchStore().delete(attemptId), + mintAttemptId: () => randomUUID(), + mintOperationId: () => mintAgentLaunchOperationId(), + mintFailureId: () => randomUUID() + } + + if ('resume' in args.agentLaunch) { + const ingest = resolveResumeLaunchIngest( + { + resume: args.agentLaunch.resume, + client: 'desktop', + legacy: { + shell: resolveStartupShell(hostState.target.platform, hostState.target.shell), + connectionId: args.connectionId ?? null, + ...(args.launchConfig + ? { + handoff: { + launchConfig: args.launchConfig, + recordedConnectionId: args.legacyResumeRecordedConnectionId ?? null + } + } + : {}) + } + }, + getHostAgentSessionRecordStore() + ) + if (!ingest.ok) { + return { agentLaunch: { status: 'failed', failure: ingest.failure } } + } + if (ingest.kind === 'legacy') { + args.command = ingest.launchCommand + args.commandDelivery = 'provider' + args.launchConfig = ingest.launchConfig + args.launchAgent = ingest.baseAgent + return null + } + resumeRequest = ingest.request + resumeIntent = ingest.intent + resumePersistedSnapshot = ingest.persistedSnapshot + resumeProviderSession = ingest.resumeProviderSession + } else if ('vaultResume' in args.agentLaunch) { + const vault = args.agentLaunch.vaultResume + if (vault.operation !== 'resume') { + return { + agentLaunch: { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + } + } + const session = await revalidateAiVaultResumeEntry( + vault.entry, + discoverAiVaultSessionsAcrossHosts + ) + if (!session) { + return { + agentLaunch: { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + } + } + const vaultResolution = resolveRevalidatedVaultResume({ + session, + sessionRecordStore: getHostAgentSessionRecordStore(), + targetExecutionHostId: hostState.target.executionHostId, + targetPlatform: hostState.target.platform, + preferredWorktreeId: + typeof args.worktreeId === 'string' && args.worktreeId.length > 0 ? args.worktreeId : null, + settings: getLaunchSettings() + }) + if (vaultResolution.kind === 'snapshot') { + const ingest = resolveResumeLaunchIngest( + { resume: vaultResolution.request.resume, client: 'desktop' }, + getHostAgentSessionRecordStore() + ) + if (!ingest.ok || ingest.kind !== 'snapshot') { + return { + agentLaunch: { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + } + } + resumeRequest = ingest.request + resumeIntent = ingest.intent + resumePersistedSnapshot = ingest.persistedSnapshot + resumeProviderSession = ingest.resumeProviderSession + } else { + const startup = vaultResolution.startup + args.command = startup.command + args.commandDelivery = 'provider' + if (startup.env) { + args.env = { ...args.env, ...startup.env } + } + if (startup.launchConfig) { + args.launchConfig = startup.launchConfig + } + args.launchAgent = session.agent + ctx.vaultLaunchNotices = vaultResolution.launchNotices ?? null + return null + } + } else { + resumeRequest = args.agentLaunch + if ( + args.agentLaunch.unattended?.kind === 'background' && + args.agentLaunch.selection.kind === 'agent' && + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 + ) { + backgroundDeclarationRequestedAgent = args.agentLaunch.selection.agent + backgroundDeclaration = beginBackgroundDeclarationLaunch(backgroundDeclarationDeps, { + worktreeId: args.worktreeId, + requestedAgent: args.agentLaunch.selection.agent + }) + resumeIntent = backgroundDeclaration.intent + } + } + + if (!resumeRequest) { + return null + } + const recipeRepo = + ctx.deps.store && typeof args.worktreeId === 'string' && args.worktreeId.length > 0 + ? (ctx.deps.store.getRepo(getRepoIdFromWorktreeId(args.worktreeId)) ?? null) + : null + const resolution = await resolveAgentLaunchSpawn( + { + getSettings: hostState.getSettings, + getCatalogRevision: hostState.getCatalogRevision, + boundary: getHostAgentLaunchBoundary() + }, + { + request: resumeRequest, + intent: resumeIntent, + target: hostState.target, + variables: hostState.variables, + recipeRepo, + scope: + backgroundDeclaration?.scope ?? + (typeof args.worktreeId === 'string' && args.worktreeId.length > 0 + ? args.worktreeId + : 'local-pty-spawn'), + principal: { kind: 'local' }, + ...(resumePersistedSnapshot ? { persistedSnapshot: resumePersistedSnapshot } : {}), + ...(resumeProviderSession ? { resumeProviderSession } : {}) + } + ) + if (!resolution.ok) { + const settled = backgroundDeclaration + ? settleBackgroundDeclarationResolution( + backgroundDeclarationDeps, + backgroundDeclaration.attemptId, + resolution + ) + : null + const backgroundAttemptId = + settled?.attemptRetained && backgroundDeclaration + ? { backgroundAttemptId: backgroundDeclaration.attemptId } + : {} + return 'failure' in resolution + ? { + agentLaunch: { + status: 'failed', + failure: resolution.failure, + ...backgroundAttemptId + } + } + : { agentLaunch: { status: 'rejected', requestError: resolution.requestError } } + } + + let settled = false + ctx.agentLaunchToken = resolution.receipt.launchToken + ctx.agentLaunchOutcome = { + status: 'launched', + receipt: resolution.receipt, + ...(backgroundDeclaration ? { backgroundAttemptId: backgroundDeclaration.attemptId } : {}) + } + ctx.settleAgentLaunch = (settlement) => { + if (settled || !ctx.agentLaunchToken) { + return + } + settled = true + getHostAgentLaunchBoundary().settleAgentLaunch(ctx.agentLaunchToken, settlement) + if (backgroundDeclaration && backgroundDeclarationRequestedAgent) { + settleBackgroundDeclarationSpawn( + backgroundDeclarationDeps, + backgroundDeclaration, + settlement, + backgroundDeclarationRequestedAgent + ) + } + } + args.command = resolution.plan.launchCommand + args.commandDelivery = 'provider' + args.launchConfig = resolution.plan.launchConfig + args.launchAgent = resolution.receipt.baseAgent + args.launchToken = resolution.receipt.launchToken + if (args.telemetry) { + args.telemetry = { + ...args.telemetry, + agent_kind: resolution.receipt.telemetry.agentKind, + used_custom_agent: resolution.receipt.telemetry.usedCustomAgent + } + } + if (resolution.plan.startupCommandDelivery !== undefined) { + args.startupCommandDelivery = resolution.plan.startupCommandDelivery + } + args.env = { + ...args.env, + ...resolution.plan.env, + ORCA_AGENT_LAUNCH_TOKEN: resolution.receipt.launchToken + } + ctx.agentLaunchFollowupPrompt = resolution.plan.followupPrompt + ctx.agentLaunchDraftPrompt = resolution.plan.draftPrompt ?? null + return null +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index f02eb215c87..c7c0c07cafa 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -6,11 +6,7 @@ import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' import { track } from '../../../telemetry/client' import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { buildAgentStartedAttribution } from '../../../telemetry/agent-started-telemetry' import { shouldSkipCodexHomeEnvForWindowsShell, codexReattachedHomeRouteField @@ -24,6 +20,9 @@ import { } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { registerHostSessionLaunch } from '../../../agent-launch/agent-session-launch-registration' +import { getHostAgentLaunchBoundary } from '../../../agent-launch/agent-launch-boundary-host' +import { getHostAgentSessionRecordStore } from '../../../agent-launch/agent-session-record-store-host' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -189,18 +188,29 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0) { + ctx.spawnOptions.launchToken = args.launchToken + } if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index f40b46e5dd5..6a04c8c400c 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -17,8 +17,14 @@ import { } from '../host-env/fresh-spawn-routing' import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' import type { PtyIpcSpawnState } from './spawn-state' +import { + resolvePtyIpcAgentLaunch, + type AgentLaunchEarlyResult +} from './spawn-agent-launch' -export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { +export async function preparePtyIpcSpawnPreflight( + ctx: PtyIpcSpawnState +): Promise { const args = ctx.args // Establish daemon identity before the first await so hidden delivery is gated before byte zero. ctx.provider = getProvider(args.connectionId) @@ -190,6 +196,10 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } } } + const agentLaunchEarlyResult = await resolvePtyIpcAgentLaunch(ctx) + if (agentLaunchEarlyResult) { + return agentLaunchEarlyResult + } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -226,4 +236,5 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) : null ctx.spawnTiming.mark('auth') + return null } diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 748eb5d8f62..b74d6304323 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -8,6 +8,7 @@ import { executePtyIpcSpawn } from './spawn-execute' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import { getHostAgentSessionRecordStore } from '../../../agent-launch/agent-session-record-store-host' function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { if (!ctx.agentTeamsLeaderHandle) { @@ -36,7 +37,10 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg return early } try { - await preparePtyIpcSpawnPreflight(ctx) + const agentLaunchEarlyResult = await preparePtyIpcSpawnPreflight(ctx) + if (agentLaunchEarlyResult) { + return agentLaunchEarlyResult + } await assemblePtyIpcSpawnEnv(ctx) const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize(ctx) @@ -48,12 +52,17 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg // evict the team env assembly created for it — exit/close cleanup keys // off handleByPtyId — so every lost race would leak one team forever. releaseAbandonedAgentTeamsLeader(ctx) + ctx.settleAgentLaunch('failed') return earlyReserved } await executePtyIpcSpawn(ctx) return await commitPtyIpcSpawn(ctx) } catch (err) { releaseAbandonedAgentTeamsLeader(ctx) + ctx.settleAgentLaunch('failed') + if (ctx.agentLaunchToken) { + getHostAgentSessionRecordStore().rollbackByToken(ctx.agentLaunchToken) + } if (ctx.preSpawnHiddenMarkId !== null) { ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) } diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index b1240c4ec6e..8927098f0e5 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -10,6 +10,8 @@ import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' import { localProvider } from '../provider/registry' import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import type { AgentLaunchSpawnOutcome } from '../../../../shared/agent-launch-spawn-request' +import type { PersistedLaunchNoticeState } from '../../../../shared/agent-launch-contract' export type PtyIpcSpawnState = { deps: PtySpawnIpcDeps @@ -83,6 +85,12 @@ export type PtyIpcSpawnState = { sessionSizeBeforeAttach: { cols: number; rows: number } | undefined initiallyHidden: boolean preSpawnHiddenMarkId: string | null + agentLaunchOutcome: AgentLaunchSpawnOutcome | null + agentLaunchFollowupPrompt: string | null + agentLaunchDraftPrompt: string | null + agentLaunchToken: string | null + vaultLaunchNotices: PersistedLaunchNoticeState | null + settleAgentLaunch: (settlement: 'registered' | 'failed') => void } export function createPtyIpcSpawnState( @@ -156,6 +164,12 @@ export function createPtyIpcSpawnState( hadSessionSizeBeforeAttach: false, sessionSizeBeforeAttach: undefined, initiallyHidden: false, - preSpawnHiddenMarkId: null + preSpawnHiddenMarkId: null, + agentLaunchOutcome: null, + agentLaunchFollowupPrompt: null, + agentLaunchDraftPrompt: null, + agentLaunchToken: null, + vaultLaunchNotices: null, + settleAgentLaunch: () => {} } } diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index af5abe564ed..fedad964324 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -20,6 +20,7 @@ import type { } from '../host-env/types' import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume' import type { StablePaneOwner } from '../pane/stable-owner' +import type { AgentLaunchInput } from '../../../../shared/agent-launch-spawn-request' export type PtySpawnIpcArgs = { cols: number @@ -35,6 +36,8 @@ export type PtySpawnIpcArgs = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: unknown launchAgent?: TuiAgent + agentLaunch?: AgentLaunchInput + legacyResumeRecordedConnectionId?: string | null startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string @@ -55,6 +58,7 @@ export type PtySpawnIpcArgs = { agent_kind?: unknown launch_source?: unknown request_kind?: unknown + used_custom_agent?: unknown } } diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts index 72286eb0a4d..a07fccf49b2 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -7,6 +7,7 @@ import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-servi import { agentHookServer } from '../../../agent-hooks/server' import { clearMigrationUnsupportedPty } from '../../../agent-hooks/migration-unsupported-pty-state' import { clearNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { getHostAgentSessionRecordStore } from '../../../agent-launch/agent-session-record-store-host' import { clearHiddenRendererPtyDeliveryState, isHiddenRendererPty @@ -100,6 +101,7 @@ export function clearProviderPtyState( } ptyPaneKey.delete(id) if (stillOwnsPaneKey) { + getHostAgentSessionRecordStore().disposeStagingForPane(paneKey) // Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't block the rest. for (const listener of paneKeyTeardownListeners) { try { diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9c46c942383..22358ae4385 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -2,6 +2,8 @@ import type { BrowserWindow } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { AgentLaunchNoticeCode } from '../../../shared/agent-launch-contract' +import { AGENT_LAUNCH_NOTICE_CODES } from '../../../shared/agent-launch-notice-schema' import { LocalPtyProvider } from '../../providers/local-pty-provider' import type { TerminalStartupCwdMissingDirFallback } from '../../../shared/terminal-startup-cwd' import { @@ -118,6 +120,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') ipcMain.removeHandler('pty:resetRendererDeliveryDebug') + ipcMain.removeHandler('pty:dismissLaunchNotice') ipcMain.removeHandler('pty:reportRendererDeliveryState') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') @@ -145,6 +148,28 @@ export function registerPtyHandlers( setRebindProviderListeners(() => bindProviderListeners(session)) installPtySerializeBufferIpc(session) + ipcMain.handle( + 'pty:dismissLaunchNotice', + async ( + _event, + args: { + worktreeId: string + tabId: string + launchToken: string + code: AgentLaunchNoticeCode + } + ): Promise<{ ok: boolean; changed: boolean }> => { + if (!runtime || !AGENT_LAUNCH_NOTICE_CODES.includes(args.code)) { + return { ok: false, changed: false } + } + return runtime.dismissLaunchNotice(`id:${args.worktreeId}`, { + tabId: args.tabId, + launchToken: args.launchToken, + code: args.code + }) + } + ) + // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. clearRendererGateResetHandlers() const resetRendererPtyDeliveryGateState = (): void => { diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 0b4e8804ac0..a7402827993 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -19,11 +19,7 @@ import { import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' import { track } from '../../../telemetry/client' import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { buildAgentStartedAttribution } from '../../../telemetry/agent-started-telemetry' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' import { isNativeWindowsLocalPtySpawn, @@ -229,18 +225,12 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { markClaudePtySpawned(ctx.result.id) } - if (args.telemetry && !ctx.stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } + const runtimeAttribution = + args.telemetry && !ctx.stablePaneOwner && !ctx.result.isReattach + ? buildAgentStartedAttribution(args.telemetry) + : null + if (runtimeAttribution) { + track('agent_started', { ...runtimeAttribution, ...getCohortAtEmit() }) } // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index fcba7770a39..09842967bc6 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -101,6 +101,9 @@ export async function buildRuntimePtySpawnOptions( if (isTuiAgent(args.launchAgent)) { ctx.spawnOptions.launchAgent = args.launchAgent } + if (typeof args.launchToken === 'string' && args.launchToken.length > 0) { + ctx.spawnOptions.launchToken = args.launchToken + } if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 81878fa029b..1000411212a 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -86,12 +86,14 @@ export type RuntimePtySpawnArgs = { cwd?: string command?: string launchAgent?: TuiAgent + launchToken?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery telemetry?: { agent_kind?: unknown launch_source?: unknown request_kind?: unknown + used_custom_agent?: unknown } env?: Record envToDelete?: string[] diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 5648cca0219..557fb7136bb 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -59,7 +59,7 @@ const { registerTerminalPreviewHandlersMock, registerSpeechHandlersMock, registerSkillsHandlersMock, - registerSkillDeleteIpcHandlersMock, + registerAgentCatalogHandlersMock, registerWorkspaceSpaceHandlersMock, registerWorkspacePortHandlersMock, registerLocalhostWorktreeLabelHandlersMock, @@ -125,7 +125,7 @@ const { registerTerminalPreviewHandlersMock: vi.fn(), registerSpeechHandlersMock: vi.fn(), registerSkillsHandlersMock: vi.fn(), - registerSkillDeleteIpcHandlersMock: vi.fn(), + registerAgentCatalogHandlersMock: vi.fn(), registerWorkspaceSpaceHandlersMock: vi.fn(), registerWorkspacePortHandlersMock: vi.fn(), registerLocalhostWorktreeLabelHandlersMock: vi.fn(), @@ -144,246 +144,246 @@ vi.mock('../../shared/runtime-environment-store', () => ({ listEnvironments: listEnvironmentsMock })) -vi.mock('../runtime-environment-transport-routing', () => ({ +vi.mock('./runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: callRuntimeEnvironmentMock })) -vi.mock('../codex-config-sync', () => ({ +vi.mock('./codex-config-sync', () => ({ registerCodexConfigSyncHandlers: registerCodexConfigSyncHandlersMock })) -vi.mock('../onboarding', () => ({ +vi.mock('./onboarding', () => ({ registerOnboardingHandlers: registerOnboardingHandlersMock })) -vi.mock('../dashboard-popout', () => ({ +vi.mock('./dashboard-popout', () => ({ registerDashboardPopoutHandlers: registerDashboardPopoutHandlersMock })) -vi.mock('../../window/dashboard-popout-window', () => ({ +vi.mock('../window/dashboard-popout-window', () => ({ isDashboardPopoutRenderer: isDashboardPopoutRendererMock })) -vi.mock('../terminal-preview', () => ({ +vi.mock('./terminal-preview', () => ({ registerTerminalPreviewHandlers: registerTerminalPreviewHandlersMock })) -vi.mock('../speech', () => ({ +vi.mock('./speech', () => ({ registerSpeechHandlers: registerSpeechHandlersMock })) -vi.mock('../cli', () => ({ +vi.mock('./cli', () => ({ registerCliHandlers: registerCliHandlersMock })) -vi.mock('../preflight', () => ({ +vi.mock('./preflight', () => ({ registerPreflightHandlers: registerPreflightHandlersMock })) -vi.mock('../usage-provider-handlers', () => ({ +vi.mock('./usage-provider-handlers', () => ({ registerUsageProviderHandlers: registerUsageProviderHandlersMock })) -vi.mock('../github', () => ({ +vi.mock('./github', () => ({ registerGitHubHandlers: registerGitHubHandlersMock })) -vi.mock('../feedback', () => ({ +vi.mock('./feedback', () => ({ registerFeedbackHandlers: registerFeedbackHandlersMock })) -vi.mock('../export', () => ({ +vi.mock('./export', () => ({ registerExportHandlers: registerExportHandlersMock })) -vi.mock('../stats', () => ({ +vi.mock('./stats', () => ({ registerStatsHandlers: registerStatsHandlersMock })) -vi.mock('../memory', () => ({ +vi.mock('./memory', () => ({ registerMemoryHandlers: registerMemoryHandlersMock })) -vi.mock('../notebook', () => ({ +vi.mock('./notebook', () => ({ registerNotebookHandlers: registerNotebookHandlersMock })) -vi.mock('../notifications', () => ({ +vi.mock('./notifications', () => ({ registerNotificationHandlers: registerNotificationHandlersMock })) -vi.mock('../developer-permissions', () => ({ +vi.mock('./developer-permissions', () => ({ registerDeveloperPermissionHandlers: registerDeveloperPermissionHandlersMock })) -vi.mock('../computer-use-permissions', () => ({ +vi.mock('./computer-use-permissions', () => ({ registerComputerUsePermissionHandlers: registerComputerUsePermissionHandlersMock })) -vi.mock('../settings', () => ({ +vi.mock('./settings', () => ({ registerSettingsHandlers: registerSettingsHandlersMock })) -vi.mock('../skills', () => ({ +vi.mock('./skills', () => ({ registerSkillsHandlers: registerSkillsHandlersMock })) -vi.mock('../skill-delete/handlers', () => ({ - registerSkillDeleteIpcHandlers: registerSkillDeleteIpcHandlersMock +vi.mock('./agent-catalog', () => ({ + registerAgentCatalogHandlers: registerAgentCatalogHandlersMock })) -vi.mock('../workspace-space', () => ({ +vi.mock('./workspace-space', () => ({ registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock })) -vi.mock('../workspace-ports', () => ({ +vi.mock('./workspace-ports', () => ({ registerWorkspacePortHandlers: registerWorkspacePortHandlersMock })) -vi.mock('../localhost-worktree-labels', () => ({ +vi.mock('./localhost-worktree-labels', () => ({ registerLocalhostWorktreeLabelHandlers: registerLocalhostWorktreeLabelHandlersMock })) -vi.mock('../keybindings', () => ({ +vi.mock('./keybindings', () => ({ registerKeybindingHandlers: registerKeybindingHandlersMock })) -vi.mock('../telemetry', () => ({ +vi.mock('./telemetry', () => ({ registerTelemetryHandlers: registerTelemetryHandlersMock })) -vi.mock('../diagnostics', () => ({ +vi.mock('./diagnostics', () => ({ registerDiagnosticsHandlers: registerDiagnosticsHandlersMock })) -vi.mock('../shell', () => ({ +vi.mock('./shell', () => ({ registerShellHandlers: registerShellHandlersMock })) -vi.mock('../pet', () => ({ +vi.mock('./pet', () => ({ registerPetHandlers: registerPetHandlersMock })) -vi.mock('../session', () => ({ +vi.mock('./session', () => ({ registerSessionHandlers: registerSessionHandlersMock })) -vi.mock('../ui', () => ({ +vi.mock('./ui', () => ({ registerUIHandlers: registerUIHandlersMock, setTrustedUIRendererWebContentsId: setTrustedUIRendererWebContentsIdMock })) -vi.mock('../emulator-frame-stream', () => ({ +vi.mock('./emulator-frame-stream', () => ({ registerEmulatorFrameStreamHandlers: registerEmulatorFrameStreamHandlersMock })) -vi.mock('../emulator-video-stream', () => ({ +vi.mock('./emulator-video-stream', () => ({ registerEmulatorVideoStreamHandlers: registerEmulatorVideoStreamHandlersMock })) -vi.mock('../filesystem', () => ({ +vi.mock('./filesystem', () => ({ registerFilesystemHandlers: registerFilesystemHandlersMock })) -vi.mock('../filesystem-watcher', () => ({ +vi.mock('./filesystem-watcher', () => ({ registerFilesystemWatcherHandlers: registerFilesystemWatcherHandlersMock })) -vi.mock('../rate-limits', () => ({ +vi.mock('./rate-limits', () => ({ registerRateLimitHandlers: registerRateLimitHandlersMock })) -vi.mock('../runtime', () => ({ +vi.mock('./runtime', () => ({ registerRuntimeHandlers: registerRuntimeHandlersMock })) -vi.mock('../runtime-environments', () => ({ +vi.mock('./runtime-environments', () => ({ registerRuntimeEnvironmentHandlers: registerRuntimeEnvironmentHandlersMock })) -vi.mock('../ephemeral-vm', () => ({ +vi.mock('./ephemeral-vm', () => ({ registerEphemeralVmHandlers: registerEphemeralVmHandlersMock })) -vi.mock('../ai-vault', () => ({ +vi.mock('./ai-vault', () => ({ registerAiVaultHandlers: registerAiVaultHandlersMock })) -vi.mock('../orca-profiles', () => ({ +vi.mock('./orca-profiles', () => ({ registerOrcaProfileHandlers: registerOrcaProfileHandlersMock })) -vi.mock('../codex-accounts', () => ({ +vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) -vi.mock('../agent-hooks', () => ({ +vi.mock('./agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) -vi.mock('../agent-trust', () => ({ +vi.mock('./agent-trust', () => ({ registerAgentTrustHandlers: registerAgentTrustHandlersMock })) -vi.mock('../claude-accounts', () => ({ +vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) -vi.mock('../minimax-credentials', () => ({ +vi.mock('./minimax-credentials', () => ({ registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock })) -vi.mock('../grok-accounts', () => ({ +vi.mock('./grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) -vi.mock('../../window/attach-main-window-services', () => ({ +vi.mock('../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) -vi.mock('../../window/clipboard-ipc-handlers', () => ({ +vi.mock('../window/clipboard-ipc-handlers', () => ({ registerClipboardHandlers: registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsId: setTrustedClipboardRendererWebContentsIdMock })) -vi.mock('../browser', () => ({ +vi.mock('./browser', () => ({ registerBrowserHandlers: registerBrowserHandlersMock, setAgentBrowserBridgeRef: setAgentBrowserBridgeRefMock })) -vi.mock('../browser-renderer-trust', () => ({ +vi.mock('./browser-renderer-trust', () => ({ setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock })) -vi.mock('../app', () => ({ +vi.mock('./app', () => ({ registerAppHandlers: registerAppHandlersMock })) -vi.mock('../terminal-render-desync-evidence', () => ({ +vi.mock('./terminal-render-desync-evidence', () => ({ registerTerminalRenderDesyncEvidenceHandler: registerTerminalRenderDesyncEvidenceHandlerMock })) -vi.mock('../linear', () => ({ +vi.mock('./linear', () => ({ registerLinearHandlers: registerLinearHandlersMock })) -vi.mock('../jira', () => ({ +vi.mock('./jira', () => ({ registerJiraHandlers: registerJiraHandlersMock })) -vi.mock('../bitbucket', () => ({ +vi.mock('./bitbucket', () => ({ registerBitbucketHandlers: registerBitbucketHandlersMock })) -vi.mock('../gitlab', () => ({ +vi.mock('./gitlab', () => ({ registerGitLabHandlers: registerGitLabHandlersMock })) -vi.mock('../hosted-review', () => ({ +vi.mock('./hosted-review', () => ({ registerHostedReviewHandlers: registerHostedReviewHandlersMock })) -vi.mock('../native-chat', () => ({ +vi.mock('./native-chat', () => ({ registerNativeChatHandlers: registerNativeChatHandlersMock })) @@ -447,7 +447,7 @@ describe('registerCoreHandlers', () => { registerTerminalPreviewHandlersMock.mockReset() registerSpeechHandlersMock.mockReset() registerSkillsHandlersMock.mockReset() - registerSkillDeleteIpcHandlersMock.mockReset() + registerAgentCatalogHandlersMock.mockReset() registerWorkspaceSpaceHandlersMock.mockReset() registerWorkspacePortHandlersMock.mockReset() registerLocalhostWorktreeLabelHandlersMock.mockReset() @@ -534,8 +534,8 @@ describe('registerCoreHandlers', () => { expect(registerDashboardPopoutHandlersMock).toHaveBeenCalledWith(store, undefined) expect(registerTerminalPreviewHandlersMock).toHaveBeenCalledWith(runtime) expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService) + expect(registerAgentCatalogHandlersMock).toHaveBeenCalledWith(store) expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store, runtime) - expect(registerSkillDeleteIpcHandlersMock).toHaveBeenCalledWith(store, runtime) expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store) expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store) expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 98cad9b25a6..c8f5da34919 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -1,96 +1,96 @@ import { app } from 'electron' -import { registerAppHandlers } from '../app' -import { registerCliHandlers } from '../cli' -import { registerPreflightHandlers } from '../preflight' -import type { Store } from '../../persistence' -import type { OrcaRuntimeService } from '../../runtime/orca-runtime' -import type { StatsCollector } from '../../stats/collector' -import { registerFilesystemHandlers } from '../filesystem' -import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' -import { registerFilesystemWatcherHandlers } from '../filesystem-watcher' -import { registerUsageProviderHandlers } from '../usage-provider-handlers' -import { registerGitHubHandlers } from '../github' -import { registerGitLabHandlers } from '../gitlab' -import { registerHostedReviewHandlers } from '../hosted-review' -import { registerLinearHandlers } from '../linear' -import { registerJiraHandlers } from '../jira' -import { registerBitbucketHandlers } from '../bitbucket' -import { registerFeedbackHandlers } from '../feedback' -import { registerCrashReportingHandlers } from '../crash-reporting' -import { registerExportHandlers } from '../export' -import { registerStatsHandlers } from '../stats' -import { registerMemoryHandlers } from '../memory' -import { registerRateLimitHandlers } from '../rate-limits' -import { registerRuntimeHandlers } from '../runtime' -import { registerRuntimeEnvironmentHandlers } from '../runtime-environments' -import { registerEphemeralVmHandlers } from '../ephemeral-vm' -import { registerAiVaultHandlers } from '../ai-vault' -import { registerNativeChatHandlers } from '../native-chat' -import { registerNotificationHandlers } from '../notifications' -import { registerNotebookHandlers } from '../notebook' -import { registerOnboardingHandlers } from '../onboarding' -import { registerDashboardPopoutHandlers } from '../dashboard-popout' -import { registerTerminalPreviewHandlers } from '../terminal-preview' -import { registerDeveloperPermissionHandlers } from '../developer-permissions' -import { registerComputerUsePermissionHandlers } from '../computer-use-permissions' -import { setAgentBrowserBridgeRef, registerBrowserHandlers } from '../browser' -import { setTrustedBrowserRendererWebContentsId } from '../browser-renderer-trust' -import { registerSessionHandlers } from '../session' -import { registerSettingsHandlers } from '../settings' -import { registerDiagnosticsHandlers } from '../diagnostics' -import { registerSkillsHandlers } from '../skills' -import { registerSkillDeleteIpcHandlers } from '../skill-delete/handlers' -import { registerWorkspaceSpaceHandlers } from '../workspace-space' -import { registerWorkspacePortHandlers } from '../workspace-ports' -import { registerLocalhostWorktreeLabelHandlers } from '../localhost-worktree-labels' -import { registerAutomationHandlers } from '../automations' -import { registerKeybindingHandlers } from '../keybindings' -import { registerTelemetryHandlers } from '../telemetry' -import { registerShellHandlers } from '../shell' -import { registerPetHandlers } from '../pet' -import { registerPluginHandlers } from '../plugins' -import { registerUIHandlers, setTrustedUIRendererWebContentsId } from '../ui' -import { registerEmulatorFrameStreamHandlers } from '../emulator-frame-stream' -import { registerEmulatorVideoStreamHandlers } from '../emulator-video-stream' -import { registerSpeechHandlers } from '../speech' -import { registerTerminalRenderDesyncEvidenceHandler } from '../terminal-render-desync-evidence' -import { registerOrcaProfileHandlers } from '../orca-profiles' -import { registerCodexAccountHandlers } from '../codex-accounts' -import { registerAgentHookHandlers } from '../agent-hooks' -import { registerCodexConfigSyncHandlers } from '../codex-config-sync' -import { getPtyIdForPaneKey } from '../pty' -import { registerAgentTrustHandlers } from '../agent-trust' -import { registerClaudeAccountHandlers } from '../claude-accounts' -import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' -import { registerGrokAccountHandlers } from '../grok-accounts' -import { registerUpdaterHandlers } from '../../window/attach-main-window-services' +import { registerAppHandlers } from './app' +import { registerCliHandlers } from './cli' +import { registerPreflightHandlers } from './preflight' +import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { StatsCollector } from '../stats/collector' +import { registerFilesystemHandlers } from './filesystem' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' +import { registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { registerUsageProviderHandlers } from './usage-provider-handlers' +import { registerGitHubHandlers } from './github' +import { registerGitLabHandlers } from './gitlab' +import { registerHostedReviewHandlers } from './hosted-review' +import { registerLinearHandlers } from './linear' +import { registerJiraHandlers } from './jira' +import { registerBitbucketHandlers } from './bitbucket' +import { registerFeedbackHandlers } from './feedback' +import { registerCrashReportingHandlers } from './crash-reporting' +import { registerExportHandlers } from './export' +import { registerStatsHandlers } from './stats' +import { registerMemoryHandlers } from './memory' +import { registerRateLimitHandlers } from './rate-limits' +import { registerRuntimeHandlers } from './runtime' +import { registerRuntimeEnvironmentHandlers } from './runtime-environments' +import { registerEphemeralVmHandlers } from './ephemeral-vm' +import { registerAiVaultHandlers } from './ai-vault' +import { registerNativeChatHandlers } from './native-chat' +import { registerNotificationHandlers } from './notifications' +import { registerNotebookHandlers } from './notebook' +import { registerOnboardingHandlers } from './onboarding' +import { registerDashboardPopoutHandlers } from './dashboard-popout' +import { registerTerminalPreviewHandlers } from './terminal-preview' +import { registerDeveloperPermissionHandlers } from './developer-permissions' +import { registerComputerUsePermissionHandlers } from './computer-use-permissions' +import { setAgentBrowserBridgeRef, registerBrowserHandlers } from './browser' +import { setTrustedBrowserRendererWebContentsId } from './browser-renderer-trust' +import { registerSessionHandlers } from './session' +import { registerSettingsHandlers } from './settings' +import { registerDiagnosticsHandlers } from './diagnostics' +import { registerSkillsHandlers } from './skills' +import { registerAgentCatalogHandlers } from './agent-catalog' +import { registerWorkspaceSpaceHandlers } from './workspace-space' +import { registerWorkspacePortHandlers } from './workspace-ports' +import { registerLocalhostWorktreeLabelHandlers } from './localhost-worktree-labels' +import { registerAutomationHandlers } from './automations' +import { registerKeybindingHandlers } from './keybindings' +import { registerTelemetryHandlers } from './telemetry' +import { registerShellHandlers } from './shell' +import { registerPetHandlers } from './pet' +import { registerPluginHandlers } from './plugins' +import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui' +import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' +import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream' +import { registerSpeechHandlers } from './speech' +import { registerTerminalRenderDesyncEvidenceHandler } from './terminal-render-desync-evidence' +import { registerOrcaProfileHandlers } from './orca-profiles' +import { registerCodexAccountHandlers } from './codex-accounts' +import { registerAgentHookHandlers } from './agent-hooks' +import { registerCodexConfigSyncHandlers } from './codex-config-sync' +import { getPtyIdForPaneKey } from './pty' +import { registerAgentTrustHandlers } from './agent-trust' +import { registerClaudeAccountHandlers } from './claude-accounts' +import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' +import { registerGrokAccountHandlers } from './grok-accounts' +import { registerUpdaterHandlers } from '../window/attach-main-window-services' import { registerClipboardHandlers, setTrustedClipboardRendererWebContentsId -} from '../../window/clipboard-ipc-handlers' -import { isDashboardPopoutRenderer } from '../../window/dashboard-popout-window' -import type { ClaudeUsageStore } from '../../claude-usage/store' -import type { CodexUsageStore } from '../../codex-usage/store' -import type { OpenCodeUsageStore } from '../../opencode-usage/store' -import type { RateLimitService } from '../../rate-limits/service' -import type { CodexAccountService } from '../../codex-accounts/service' -import type { ClaudeAccountService } from '../../claude-accounts/service' -import type { AutomationService } from '../../automations/service' -import type { AgentAwakeService } from '../../agent-awake-service' -import type { CrashReportStore } from '../../crash-reporting/crash-report-store' -import type { KeybindingService } from '../../keybindings/keybinding-service' +} from '../window/clipboard-ipc-handlers' +import { isDashboardPopoutRenderer } from '../window/dashboard-popout-window' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { RateLimitService } from '../rate-limits/service' +import type { CodexAccountService } from '../codex-accounts/service' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { AutomationService } from '../automations/service' +import type { AgentAwakeService } from '../agent-awake-service' +import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import type { KeybindingService } from '../keybindings/keybinding-service' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult -} from '../../../shared/ai-vault-resume-preparation' +} from '../../shared/ai-vault-resume-preparation' import { getSavedRuntimeAiVaultHostInfos, prepareRuntimeAiVaultSessionResume, resolveRuntimeAiVaultSessionTitles, scanRuntimeAiVaultSessions -} from '../../ai-vault/runtime-session-scanner' -import type { PluginService } from '../../plugins/plugin-service' -import type { PluginMarketplaceHandlerServices } from '../plugin-marketplaces' +} from '../ai-vault/runtime-session-scanner' +import type { PluginService } from '../plugins/plugin-service' +import type { PluginMarketplaceHandlerServices } from './plugin-marketplaces' let registered = false @@ -176,8 +176,8 @@ export function registerCoreHandlers( registerTerminalRenderDesyncEvidenceHandler() registerComputerUsePermissionHandlers() registerSettingsHandlers(store, agentAwakeService) + registerAgentCatalogHandlers(store) registerSkillsHandlers(store, runtime) - registerSkillDeleteIpcHandlers(store, runtime) if (automations) { registerAutomationHandlers(store, automations) } @@ -215,7 +215,6 @@ export function registerCoreHandlers( registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store, pluginService) registerAiVaultHandlers({ - ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index a6a90f2b9bf..c9f4e68b4db 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -480,6 +480,41 @@ describe('registerSettingsHandlers', () => { ) }) + it('strips catalog- and reference-owned keys from generic renderer settings writes', async () => { + store.getSettings.mockReturnValue({}) + store.updateSettings.mockReturnValue({}) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { + defaultTuiAgent: 'codex', + disabledTuiAgents: ['codex'], + customTuiAgents: [{ id: 'custom-agent:codex:x', env: { LEAK: 'v' } }], + deletedCustomTuiAgents: [], + agentCatalogSchemaVersion: 99, + agentCatalogRevision: 99, + agentCmdOverrides: { codex: '/evil' }, + agentDefaultArgs: { codex: '--evil' }, + agentDefaultEnv: { codex: { EVIL: '1' } }, + agentReferenceRevision: 99, + terminalQuickCommands: [], + commitMessageAi: { enabled: true }, + sourceControlAi: { enabled: true }, + tabAutoGenerateTitle: true + }) + + // Only the non-owned key survives; owned keys go through the atomic + // catalog/reference mutation APIs instead. + expect(store.updateSettings).toHaveBeenCalledWith( + { tabAutoGenerateTitle: true }, + { notifyListeners: true, originWebContentsId: 1 } + ) + }) + it('normalizes terminal scrollback row updates and drops legacy byte updates', async () => { store.getSettings.mockReturnValue({ terminalScrollbackRows: 5_000 }) store.updateSettings.mockReturnValue({ terminalScrollbackRows: 50_000 }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 1d4194825d9..28b841ee823 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -46,6 +46,28 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial & { terminalScrollbackBytes?: unknown } +// Why: these keys are owned by the atomic catalog/reference mutation APIs +// (settings.mutateAgentCatalog and the owner-specific reference mutations). +// Accepting them through the generic settings write would bypass revision +// checks, id minting, tombstone bookkeeping, and the reference index. +const AGENT_CATALOG_OWNED_SETTINGS_KEYS = [ + 'defaultTuiAgent', + 'disabledTuiAgents', + 'customTuiAgents', + 'deletedCustomTuiAgents', + 'agentCatalogSchemaVersion', + 'agentCatalogRevision', + 'agentCmdOverrides', + 'agentDefaultArgs', + 'agentDefaultEnv' +] as const +const AGENT_REFERENCE_OWNED_SETTINGS_KEYS = [ + 'agentReferenceRevision', + 'terminalQuickCommands', + 'commitMessageAi', + 'sourceControlAi' +] as const + function sanitizeRendererSettingsUpdate(args: Partial): Partial { const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedArgs } = args as LegacyTerminalScrollbackSettingsUpdate @@ -54,6 +76,12 @@ function sanitizeRendererSettingsUpdate(args: Partial): Partial< // writes must pass the dedicated reviewed-fingerprint handlers. delete sanitizedArgs.pluginConsents delete sanitizedArgs.disabledPlugins + for (const key of AGENT_CATALOG_OWNED_SETTINGS_KEYS) { + delete sanitizedArgs[key] + } + for (const key of AGENT_REFERENCE_OWNED_SETTINGS_KEYS) { + delete sanitizedArgs[key] + } return sanitizedArgs } diff --git a/src/main/ipc/worktree-logic-created-agent.test.ts b/src/main/ipc/worktree-logic-created-agent.test.ts index 0854006d308..956b0cfdd3d 100644 --- a/src/main/ipc/worktree-logic-created-agent.test.ts +++ b/src/main/ipc/worktree-logic-created-agent.test.ts @@ -1,32 +1,77 @@ import { describe, expect, it } from 'vitest' import { mergeWorktree } from './worktree-logic' +import type { GitWorktreeInfo, WorktreeMeta } from '../../shared/types' + +const GIT: GitWorktreeInfo = { + path: '/workspaces/feature', + head: 'abc123', + branch: 'refs/heads/feature-x', + isBare: false, + isMainWorktree: false +} + +const BASE_META: WorktreeMeta = { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 +} describe('mergeWorktree creation agent metadata', () => { it('forwards the creation agent metadata', () => { - const result = mergeWorktree( - 'repo1', - { - path: '/workspaces/feature', - head: 'abc123', - branch: 'refs/heads/feature-x', - isBare: false, - isMainWorktree: false - }, - { - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - createdWithAgent: 'codex' - } - ) + const result = mergeWorktree('repo1', GIT, { ...BASE_META, createdWithAgent: 'codex' }) expect(result.createdWithAgent).toBe('codex') }) }) + +describe('mergeWorktree recovery-card projection', () => { + it('mirrors the client-safe launch failure + pending record without leaking secrets', () => { + const result = mergeWorktree('repo1', GIT, { + ...BASE_META, + agentLaunchFailure: { + version: 1, + failureId: 'fail-1', + intent: 'interactive', + occurredAt: 42, + code: 'spawn_failed', + requestedAgent: 'custom-agent:codex:abc', + baseAgent: 'codex' + }, + pendingAgentLaunch: { operationId: 'op-1', requestedAgent: 'codex', priorFailureId: 'fail-0' } + }) + + expect(result.agentLaunchFailure).toEqual({ + version: 1, + failureId: 'fail-1', + intent: 'interactive', + occurredAt: 42, + code: 'spawn_failed', + requestedAgent: 'custom-agent:codex:abc', + baseAgent: 'codex' + }) + expect(result.pendingAgentLaunch).toEqual({ + operationId: 'op-1', + requestedAgent: 'codex', + priorFailureId: 'fail-0' + }) + // The Worktree DTO crosses RPC to mobile/paired clients: the recovery-card + // mirrors must never carry the private launch snapshot, token, argv, env, or + // resolved path. + const serialized = JSON.stringify(result) + expect(serialized).not.toMatch(/launchToken|snapshot|agentEnv|argv|launchConfig/) + }) + + it('omits both fields when the worktree has no pending launch or failure', () => { + const result = mergeWorktree('repo1', GIT, BASE_META) + + expect(result).not.toHaveProperty('agentLaunchFailure') + expect(result).not.toHaveProperty('pendingAgentLaunch') + }) +}) diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 7cd2e296ffd..58b4f7deac7 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -105,6 +105,15 @@ export function mergeWorktree( // Why: diff comments are persisted on WorktreeMeta and forwarded verbatim // so the renderer store mirrors on-disk state. diffComments: meta?.diffComments, - mobileDiffReview: meta?.mobileDiffReview + mobileDiffReview: meta?.mobileDiffReview, + // Client-safe mirrors that light up the post-create recovery card: the + // failure is codes+hints only, the pending record is anti-race guards + + // display attribution. The private launch snapshot/token never live here. + ...(meta?.agentLaunchFailure !== undefined + ? { agentLaunchFailure: meta.agentLaunchFailure } + : {}), + ...(meta?.pendingAgentLaunch !== undefined + ? { pendingAgentLaunch: meta.pendingAgentLaunch } + : {}) } } diff --git a/src/main/persistence-agent-catalog-migration.test.ts b/src/main/persistence-agent-catalog-migration.test.ts new file mode 100644 index 00000000000..aa128be7a35 --- /dev/null +++ b/src/main/persistence-agent-catalog-migration.test.ts @@ -0,0 +1,149 @@ +// Store-level round trip for the agent-catalog v1 schema migration: legacy +// Auto mapping, pinned pre-v1 backup, idempotence, backup-failure fail-closed +// behavior, and post-v1 repair-null preservation. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('encrypted:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('encrypted:'.length) + } + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => null), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +async function createStore(dataFile: string) { + vi.resetModules() + const { Store } = await import('./persistence') + return new Store({ dataFile }) +} + +function writeProfile(dataFile: string, settings: Record): void { + writeFileSync(dataFile, JSON.stringify({ settings }), { mode: 0o600 }) +} + +const PINNED_BACKUP_SUFFIX = '.pre-agent-catalog-v1.backup' + +describe('agent-catalog v1 migration through the real Store', () => { + let dir = '' + let dataFile = '' + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-store-')) + testState.dir = dir + dataFile = join(dir, 'orca-data.json') + }) + + afterEach(() => { + chmodSync(dir, 0o755) + rmSync(dir, { recursive: true, force: true }) + }) + + it('maps a shipped legacy null default to auto once, pinning a same-permission backup', async () => { + const original = JSON.stringify({ settings: { defaultTuiAgent: null } }) + writeFileSync(dataFile, original, { mode: 0o600 }) + const store = await createStore(dataFile) + expect(store.getSettings().defaultTuiAgent).toBe('auto') + expect(store.getSettings().agentCatalogSchemaVersion).toBe(1) + expect(store.getAgentCatalogMigrationError()).toBeNull() + + const backupFile = `${dataFile}${PINNED_BACKUP_SUFFIX}` + expect(existsSync(backupFile)).toBe(true) + expect(readFileSync(backupFile, 'utf-8')).toBe(original) + expect(statSync(backupFile).mode & 0o777).toBe(0o600) + }) + + it('preserves an explicit blank or concrete default while stamping v1', async () => { + for (const explicit of ['blank', 'codex']) { + const file = join(dir, `orca-data-${explicit}.json`) + writeProfile(file, { defaultTuiAgent: explicit }) + const store = await createStore(file) + expect(store.getSettings().defaultTuiAgent).toBe(explicit) + expect(store.getSettings().agentCatalogSchemaVersion).toBe(1) + } + }) + + it('is idempotent: a second load neither remaps nor rewrites the backup', async () => { + const original = JSON.stringify({ settings: { defaultTuiAgent: null } }) + writeFileSync(dataFile, original, { mode: 0o600 }) + const first = await createStore(dataFile) + expect(first.getSettings().defaultTuiAgent).toBe('auto') + // Simulate the post-migration persisted file, then load again. + writeProfile(dataFile, { + defaultTuiAgent: 'auto', + agentCatalogSchemaVersion: 1, + agentCatalogRevision: 1, + agentReferenceRevision: 1 + }) + const second = await createStore(dataFile) + expect(second.getSettings().defaultTuiAgent).toBe('auto') + expect(readFileSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`, 'utf-8')).toBe(original) + }) + + it('keeps a post-v1 repair null default as null (never re-Auto)', async () => { + writeProfile(dataFile, { + defaultTuiAgent: null, + agentCatalogSchemaVersion: 1, + agentCatalogRevision: 4, + agentReferenceRevision: 2 + }) + const store = await createStore(dataFile) + expect(store.getSettings().defaultTuiAgent).toBeNull() + expect(store.getSettings().agentCatalogRevision).toBe(4) + // No pinned backup is created for an already-v1 profile. + expect(existsSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`)).toBe(false) + }) + + it('performs no v1 write when the pinned backup cannot be created', async () => { + writeFileSync(dataFile, JSON.stringify({ settings: { defaultTuiAgent: null } }), { + mode: 0o600 + }) + // A read-only directory makes the backup tmp-file creation fail. + chmodSync(dir, 0o500) + const store = await createStore(dataFile) + chmodSync(dir, 0o755) + expect(store.getAgentCatalogMigrationError()).not.toBeNull() + const settings = store.getSettings() + // Legacy semantics stay intact: null still means Auto through the legacy + // adapters, and no v1 marker or catalog array leaks into the state. + expect(settings.defaultTuiAgent).toBeNull() + expect(settings.agentCatalogSchemaVersion).toBeUndefined() + expect(settings.agentCatalogRevision).toBeUndefined() + expect(existsSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`)).toBe(false) + }) + + it('gives a fresh install v1 defaults directly with no backup', async () => { + const store = await createStore(dataFile) + expect(store.getSettings().defaultTuiAgent).toBe('auto') + expect(store.getSettings().agentCatalogSchemaVersion).toBe(1) + expect(existsSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`)).toBe(false) + }) +}) diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index 5bd6e572ab1..6b86e94de9e 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -41,6 +41,10 @@ import { hasStateBackup } from './backup-recovery-rotation' import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' +import { + createPinnedPreV1Backup, + migrateAgentCatalogSchema +} from '../../agent-launch/agent-catalog-schema-migration' type PersistenceStartupDetails = Record | (() => Record) @@ -64,6 +68,7 @@ type LoadedStateParsingOperationsRuntime = Pick< | 'dataFile' | 'githubCacheDirty' | 'loadNeedsSave' + | 'agentCatalogMigrationError' | 'protectedSecrets' | 'storageAuthority' | 'terminalScrollbackSnapshotStorage' @@ -97,6 +102,21 @@ export class LoadedStateParsingOperations { const parsed = JSON.parse(raw) as PersistedState logPersistenceStartupMilestone('persistence-json-parse-done') + const agentCatalogMigration = migrateAgentCatalogSchema({ + settings: parsed.settings, + preV1RawContents: raw, + createBackup: () => createPinnedPreV1Backup(dataFile, raw) + }) + if (agentCatalogMigration.didMigrate || agentCatalogMigration.backupError) { + this.runtime.loadNeedsSave = + this.runtime.loadNeedsSave || agentCatalogMigration.didMigrate + this.runtime.agentCatalogMigrationError = agentCatalogMigration.backupError ?? null + parsed.settings = { + ...parsed.settings, + ...agentCatalogMigration.settingsPatch + } + } + // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 0ec4e383c34..1bd1f5bcbe3 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -22,6 +22,7 @@ import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-gener type ProfilePreferencesRuntime = Pick< StoreRuntimeState, | 'activeViewPreference' + | 'agentCatalogMigrationError' | 'githubCacheDirty' | 'githubCacheGeneration' | 'protectedSecrets' @@ -47,6 +48,10 @@ export class ProfilePreferences { return this[profilePreferencesContext].runtime.state.settings } + getAgentCatalogMigrationError(): string | null { + return this[profilePreferencesContext].runtime.agentCatalogMigrationError + } + onSettingsChanged( listener: ( updates: Partial, diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index b14ea8ce3a4..48a820836a1 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -53,6 +53,7 @@ export class StoreRuntimeState { readonly gitUsernameCache = new Map() readonly protectedSecrets = new ProtectedSecretPersistence() loadNeedsSave = false + agentCatalogMigrationError: string | null = null flushOrThrow!: () => void settingsChangeListeners = new Set< ( diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 35fca5b0b34..f5beb7b9f2d 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -57,6 +57,8 @@ export type PtySpawnOptions = { startupCommandDelivery?: StartupCommandDelivery /** Minimal allowlisted launch ownership preserved by daemon reattach. */ launchAgent?: TuiAgent + /** Host admission launch token persisted with remote PTY ownership. */ + launchToken?: string /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ worktreeId?: string diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 9f493d9e778..fc5cd0b2dbd 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -43,6 +43,8 @@ export function buildSshPtySpawnRequest(args: { startupIngress: options.startupIngress } : {}), + // Why: relay persistence must receive admission identity in the spawn request. + ...(options.launchToken ? { launchToken: options.launchToken } : {}), ...(options.agentSessionEnsure ? { agentSessionEnsure: options.agentSessionEnsure } : {}), ...(args.supportsCreateOperation ? { agentSessionCreateOperationId: options.agentSessionCreateOperationId } diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 4f984559a63..44ba7d94637 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -14,9 +14,9 @@ import { } from '../powershell-osc133-bootstrap' import { quoteStartupArg } from '../../shared/tui-agent-startup-shell' -const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 -const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 -const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 +export const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 +export const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 +export const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' export const ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV = 'ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE' const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}%%ORCA_CODEX_LAUNCH_PREFLIGHT%%${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}% agent hooks prepare-codex > nul 2>&1` diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts index 57f867cb1c2..63f3514a1f9 100644 --- a/src/main/runtime/claude-agent-teams-service.test.ts +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { ClaudeAgentTeamsService, type AgentTeamsTerminalApi } from './claude-agent-teams-service' +import { + ClaudeAgentTeamsService, + stripEphemeralAgentTeamsEnv, + type AgentTeamsTerminalApi +} from './claude-agent-teams-service' function createServiceWithLeader(): { service: ClaudeAgentTeamsService @@ -317,4 +321,64 @@ describe('ClaudeAgentTeamsService', () => { } } }) + + it('propagates validated custom agent env to teammate panes while replacing pane identity', async () => { + const service = new ClaudeAgentTeamsService() + const launch = service.createLaunchEnv({ + leaderHandle: 'leader-handle', + baseEnv: { PATH: '/usr/bin' }, + shimDir: '/tmp/orca-shim', + shimBin: '/usr/bin/orca', + childEnv: { MY_CUSTOM_TOKEN: 'user-value', PATH: '/custom/bin' } + }) + // The leader env exposed to the caller stays generated-only; custom env + // reaches the leader through its own spawn env. + expect(launch.env.MY_CUSTOM_TOKEN).toBeUndefined() + + let splitEnv: Record | undefined + const api: AgentTeamsTerminalApi = { + splitTerminal: vi.fn(async (_handle, opts) => { + splitEnv = opts.env + return { handle: 'teammate-1', tabId: 'tab-1', paneRuntimeId: -1 } + }), + readTerminal: vi.fn(), + sendTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + showTerminal: vi.fn() + } as unknown as AgentTeamsTerminalApi + + await service.handleTmuxCompat( + { + teamId: launch.teamId, + token: launch.token, + envPane: launch.leaderPane, + argv: ['split-window', '-t', launch.leaderPane, '-h', '-P', '-F', '#{pane_id}'] + }, + api + ) + + // Teammate pane inherits custom env, keeps the shared team token, and takes + // its own pane identity (generated team keys still override any custom PATH). + expect(splitEnv?.MY_CUSTOM_TOKEN).toBe('user-value') + expect(splitEnv?.ORCA_AGENT_TEAMS_TOKEN).toBe(launch.token) + expect(splitEnv?.TMUX_PANE).toBe('%2') + expect(splitEnv?.TMUX_PANE).not.toBe(launch.leaderPane) + expect(splitEnv?.PATH).toBe(launch.env.PATH) + }) +}) + +describe('stripEphemeralAgentTeamsEnv', () => { + it('drops generated team identity but keeps custom agent env', () => { + const cleaned = stripEphemeralAgentTeamsEnv({ + CLAUDE_PROFILE: 'captured', + MY_KEY: 'v', + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + TMUX: '/tmp/x,0,1', + TMUX_PANE: '%1', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-x', + ORCA_AGENT_TEAMS_TOKEN: 'tok' + }) + expect(cleaned).toEqual({ CLAUDE_PROFILE: 'captured', MY_KEY: 'v' }) + }) }) diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index 34ef35ecb36..e2909a93f79 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -11,6 +11,23 @@ import type { TeamPane } from './claude-agent-teams-types' +const EPHEMERAL_AGENT_TEAMS_ENV_PREFIX = 'ORCA_AGENT_TEAMS_' +const EPHEMERAL_AGENT_TEAMS_ENV_KEYS = new Set([ + 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', + 'TMUX', + 'TMUX_PANE' +]) + +export function stripEphemeralAgentTeamsEnv(env: Record): Record { + return Object.fromEntries( + Object.entries(env).filter( + ([key]) => + !key.startsWith(EPHEMERAL_AGENT_TEAMS_ENV_PREFIX) && + !EPHEMERAL_AGENT_TEAMS_ENV_KEYS.has(key) + ) + ) +} + export type { AgentTeamsLaunchEnv, AgentTeamsTerminalApi, @@ -28,6 +45,8 @@ export class ClaudeAgentTeamsService { shimDir: string /** Absolute path only; null leaves the var unset so the shim refuses to guess a cwd-relative CLI. */ shimBin: string | null + /** Validated custom-agent env inherited by teammate panes. */ + childEnv?: Record }): AgentTeamsLaunchEnv { const teamId = `team-${randomUUID()}` const token = randomBytes(32).toString('base64url') @@ -69,7 +88,7 @@ export class ClaudeAgentTeamsService { sessionName: 'orca', windowIndex: '0', tmuxValue, - baseEnv: env, + baseEnv: args.childEnv ? { ...args.childEnv, ...env } : env, panes: new Map([[leaderPane, leader]]), paneOrder: [leaderPane], nextPaneNumber: 2, diff --git a/src/main/runtime/orca-runtime-agent-catalog-events.test.ts b/src/main/runtime/orca-runtime-agent-catalog-events.test.ts new file mode 100644 index 00000000000..23428819d2a --- /dev/null +++ b/src/main/runtime/orca-runtime-agent-catalog-events.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { Store } from '../persistence' +import type { GlobalSettings } from '../../shared/types' +import type { RuntimeClientEvent } from '../../shared/runtime-client-events' + +type SettingsListener = (updates: Partial, settings: GlobalSettings) => void + +describe('runtime agent catalog/reference client events', () => { + it('emits exactly one client event per revision change', () => { + let listener: SettingsListener | null = null + const store = { + onSettingsChanged: vi.fn((registered: SettingsListener) => { + listener = registered + return () => {} + }) + } as unknown as Store + const runtime = new OrcaRuntimeService(null, undefined, { agentCatalogStore: store }) + const events: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => events.push(event)) + + expect(listener).not.toBeNull() + const emit = (updates: Partial): void => + listener!(updates, {} as GlobalSettings) + + emit({ agentCatalogRevision: 5 }) + emit({ agentReferenceRevision: 3 }) + // One mutation that advances both revisions (final tombstone prune) emits both. + emit({ agentCatalogRevision: 6, agentReferenceRevision: 4 }) + // An unrelated settings write emits nothing. + emit({ workspaceDir: '/tmp' } as Partial) + + expect(events).toEqual([ + { type: 'agentCatalogChanged', revision: 5 }, + { type: 'agentReferencesChanged', revision: 3 }, + { type: 'agentCatalogChanged', revision: 6 }, + { type: 'agentReferencesChanged', revision: 4 } + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-agent-launch-delivery.test.ts b/src/main/runtime/orca-runtime-agent-launch-delivery.test.ts new file mode 100644 index 00000000000..4ab84a11c50 --- /dev/null +++ b/src/main/runtime/orca-runtime-agent-launch-delivery.test.ts @@ -0,0 +1,220 @@ +// Post-ready prompt delivery for host-spawned agent terminals. A host-spawned +// terminal (created-worktree OR a background terminal-create) has no renderer +// writer armed, so the host must deliver stdin-after-start (followupPrompt) and +// no-native-affordance draft (draftPrompt) prompts itself through one shared +// writer (deliverTerminalLaunchPrompt); command-deliverable modes carry no +// post-ready text. This exercises only the delivery routing — the readiness +// writers' internal polling/paste is unit-tested separately. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { ResolvedTerminalPostReadyPrompt } from './terminal-agent-launch-resolution' +import type { AgentStartupPlan } from '../../shared/tui-agent-startup' +import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract' + +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') } +})) + +type DeliveryInternals = { + deliverWorktreeAgentLaunchPrompt: ( + handle: string, + plan: AgentStartupPlan, + receipt: AgentLaunchReceipt + ) => void + deliverTerminalLaunchPrompt: ( + handle: string, + baseAgent: string, + postReady: ResolvedTerminalPostReadyPrompt + ) => void + sendStartupFollowupWhenReady: (handle: string, followup: unknown) => void + pasteStartupDraftWhenReady: (handle: string, draft: unknown) => void +} + +// A custom requested agent whose base is a built-in — proves the draft ready +// signal keys off the base agent (custom ids are not in TUI_AGENT_CONFIG). +const RECEIPT: AgentLaunchReceipt = { + requestedAgent: 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd', + baseAgent: 'codex', + notices: [], + launchToken: 'tok-1', + catalogRevision: 1, + telemetry: { agentKind: 'codex', usedCustomAgent: true } +} + +function basePlan(overrides: Partial): AgentStartupPlan { + return { + agent: RECEIPT.requestedAgent, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + ...overrides + } +} + +function armDeliverySpies(runtime: OrcaRuntimeService): { + internals: DeliveryInternals + followup: ReturnType + draft: ReturnType +} { + const internals = runtime as unknown as DeliveryInternals + const followup = vi.fn() + const draft = vi.fn() + internals.sendStartupFollowupWhenReady = followup + internals.pasteStartupDraftWhenReady = draft + return { internals, followup, draft } +} + +describe('deliverWorktreeAgentLaunchPrompt', () => { + it('submits a stdin-after-start followup prompt with the resolved expected process', () => { + const runtime = new OrcaRuntimeService() + const { internals, followup, draft } = armDeliverySpies(runtime) + + internals.deliverWorktreeAgentLaunchPrompt( + 'term-1', + basePlan({ followupPrompt: 'do the thing' }), + RECEIPT + ) + + expect(followup).toHaveBeenCalledWith('term-1', { + expectedProcess: 'codex', + prompt: 'do the thing' + }) + expect(draft).not.toHaveBeenCalled() + }) + + it('pastes a no-affordance draft unsubmitted, keyed off the base agent', () => { + const runtime = new OrcaRuntimeService() + const { internals, followup, draft } = armDeliverySpies(runtime) + + internals.deliverWorktreeAgentLaunchPrompt( + 'term-2', + basePlan({ draftPrompt: 'draft body' }), + RECEIPT + ) + + // Keyed off the base agent (codex), NOT the custom requestedAgent id. + expect(draft).toHaveBeenCalledWith('term-2', { agent: 'codex', content: 'draft body' }) + expect(followup).not.toHaveBeenCalled() + }) + + it('delivers nothing for a command-deliverable plan (argv/flag/env prompt)', () => { + const runtime = new OrcaRuntimeService() + const { internals, followup, draft } = armDeliverySpies(runtime) + + internals.deliverWorktreeAgentLaunchPrompt( + 'term-3', + basePlan({ launchCommand: 'codex --prompt "inline"' }), + RECEIPT + ) + + expect(followup).not.toHaveBeenCalled() + expect(draft).not.toHaveBeenCalled() + }) +}) + +// The extracted shared writer the background terminal-create path uses directly +// (worktree-create delegates to it). Same routing contract, keyed off the base +// agent + resolved post-ready prompt rather than a full startup plan. +describe('deliverTerminalLaunchPrompt', () => { + it('submits a stdin-after-start followup with the resolved expected process', () => { + const runtime = new OrcaRuntimeService() + const { internals, followup, draft } = armDeliverySpies(runtime) + + internals.deliverTerminalLaunchPrompt('term-1', 'codex', { + expectedProcess: 'codex', + followupPrompt: 'do the thing' + }) + + expect(followup).toHaveBeenCalledWith('term-1', { + expectedProcess: 'codex', + prompt: 'do the thing' + }) + expect(draft).not.toHaveBeenCalled() + }) + + it('pastes a no-affordance draft unsubmitted, keyed off the passed base agent', () => { + const runtime = new OrcaRuntimeService() + const { internals, followup, draft } = armDeliverySpies(runtime) + + internals.deliverTerminalLaunchPrompt('term-2', 'codex', { + expectedProcess: 'codex', + draftPrompt: 'draft body' + }) + + expect(draft).toHaveBeenCalledWith('term-2', { agent: 'codex', content: 'draft body' }) + expect(followup).not.toHaveBeenCalled() + }) + + it('delivers nothing when neither followup nor draft text is present', () => { + const runtime = new OrcaRuntimeService() + const { internals, followup, draft } = armDeliverySpies(runtime) + + internals.deliverTerminalLaunchPrompt('term-3', 'codex', { expectedProcess: 'codex' }) + + expect(followup).not.toHaveBeenCalled() + expect(draft).not.toHaveBeenCalled() + }) +}) + +// Option A (host-emits-on-create): the create spawn threads the receipt's +// host-derived kind + used_custom_agent plus the surface fields into the terminal +// spawn ONLY for an interactive create — the host emits agent_started at the +// registered PTY. An unattended create passes no surface telemetry and emits +// nothing. +describe('spawnWorktreeAgentLaunchTerminal agent_started threading', () => { + type SpawnInternals = { + spawnWorktreeAgentLaunchTerminal: ( + worktreeId: string, + plan: AgentStartupPlan, + receipt: AgentLaunchReceipt, + surfaceTelemetry?: { launch_source: string; request_kind: string } + ) => Promise<{ terminalId: string }> + createTerminal: (worktreeId: string, opts: { telemetry?: unknown }) => Promise<{ handle: string }> + deliverWorktreeAgentLaunchPrompt: (...args: unknown[]) => void + } + + function armSpawn(runtime: OrcaRuntimeService): { + internals: SpawnInternals + createTerminal: ReturnType + } { + const internals = runtime as unknown as SpawnInternals + const createTerminal = vi.fn(async () => ({ handle: 'term-create' })) + internals.createTerminal = createTerminal as never + // Stub post-ready delivery and receipt bookkeeping side effects so the test + // isolates the telemetry-threading decision. + internals.deliverWorktreeAgentLaunchPrompt = vi.fn() + return { internals, createTerminal } + } + + it('threads host kind + used_custom_agent with surface fields for an interactive create', async () => { + const runtime = new OrcaRuntimeService() + const { internals, createTerminal } = armSpawn(runtime) + + await internals.spawnWorktreeAgentLaunchTerminal('wt-1', basePlan({}), RECEIPT, { + launch_source: 'new_workspace_composer', + request_kind: 'new' + }) + + const opts = createTerminal.mock.calls[0]![1] as { telemetry?: unknown } + expect(opts.telemetry).toEqual({ + agent_kind: 'codex', + launch_source: 'new_workspace_composer', + request_kind: 'new', + used_custom_agent: true + }) + }) + + it('omits telemetry for an unattended create (no host emit)', async () => { + const runtime = new OrcaRuntimeService() + const { internals, createTerminal } = armSpawn(runtime) + + await internals.spawnWorktreeAgentLaunchTerminal('wt-2', basePlan({}), RECEIPT) + + const opts = createTerminal.mock.calls[0]![1] as { telemetry?: unknown } + expect(opts.telemetry).toBeUndefined() + }) +}) diff --git a/src/main/runtime/orca-runtime-agent-launch.test.ts b/src/main/runtime/orca-runtime-agent-launch.test.ts new file mode 100644 index 00000000000..73257030dc5 --- /dev/null +++ b/src/main/runtime/orca-runtime-agent-launch.test.ts @@ -0,0 +1,109 @@ +// createTerminal's host-resolved agentLaunch wiring: the resolved plan (never the +// client command) spawns exactly one PTY; a pre-spawn typed failure creates none +// and returns the failure arm. Resolution itself is unit-tested separately; here +// it is mocked so the test exercises only createTerminal's spawn/settle wiring. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { resolveTerminalAgentLaunch } from './terminal-agent-launch-resolution' + +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('./terminal-agent-launch-resolution', () => ({ + resolveTerminalAgentLaunch: vi.fn() +})) + +const resolveMock = vi.mocked(resolveTerminalAgentLaunch) + +function stubLaunchScope(runtime: OrcaRuntimeService, path = '/repo/app'): void { + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: 'wt-1', + path, + connectionId: null, + repo: null, + folderWorkspace: null + }) +} + +const RECEIPT = { + requestedAgent: 'claude' as const, + baseAgent: 'claude' as const, + notices: [], + launchToken: 'tok-1', + catalogRevision: 1, + telemetry: { agentKind: 'claude-code' as const, usedCustomAgent: false } +} + +describe('createTerminal host-resolved agentLaunch', () => { + it('spawns exactly one PTY from the resolved plan, ignoring the client command', async () => { + resolveMock.mockResolvedValue({ + kind: 'resolved', + admissionToken: 'tok-1', + receipt: RECEIPT, + fields: { + command: 'claude --tui', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchAgent: 'claude', + launchToken: 'tok-1' + } + }) + const runtime = new OrcaRuntimeService() + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const result = await runtime.createTerminal('id:wt-1', { + command: 'evil --client-controlled', + agentLaunch: { selection: { kind: 'agent', agent: 'claude' }, prompt: 'hi' } + }) + + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ command: 'claude --tui' })) + expect('handle' in result).toBe(true) + if (!('handle' in result)) { + return + } + expect(result.ptyId).toBe('pty-1') + expect(result.agentLaunch).toEqual({ status: 'launched', receipt: RECEIPT }) + }) + + it('creates no PTY and returns the failure arm for a pre-spawn typed failure', async () => { + resolveMock.mockResolvedValue({ + kind: 'failed', + outcome: { status: 'failed', failure: { code: 'base_agent_disabled', baseAgent: 'claude' } } + }) + const runtime = new OrcaRuntimeService() + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const result = await runtime.createTerminal('id:wt-1', { + agentLaunch: { selection: { kind: 'agent', agent: 'claude' }, prompt: 'hi' } + }) + + expect(spawn).not.toHaveBeenCalled() + expect(result).toEqual({ + agentLaunch: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + } + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-automation-launch-classify.test.ts b/src/main/runtime/orca-runtime-automation-launch-classify.test.ts new file mode 100644 index 00000000000..69a07978313 --- /dev/null +++ b/src/main/runtime/orca-runtime-automation-launch-classify.test.ts @@ -0,0 +1,75 @@ +// U6: the runtime backing for the automation resolve-only agent-launch gate. +// classifyAgentLaunchForAutomation resolves the agent WITHOUT spawning and maps a +// known launch failure to a PLAIN structured failure; the service stamps the +// persisted wrapper at its single persist point (ledger #12). A resolvable agent +// returns null so dispatch proceeds. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +const REPO: Repo = { + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1 +} + +describe('OrcaRuntimeService.classifyAgentLaunchForAutomation (U6)', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-classify-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('returns a plain structured failure for a disabled base agent (no wrapper mint)', async () => { + const store = await createStore() + store.addRepo(REPO) + store.updateSettings({ disabledTuiAgents: ['claude'] }) + const runtime = new OrcaRuntimeService(store) + + const failure = runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1') + + expect(failure).not.toBeNull() + expect(failure?.code).toBe('base_agent_disabled') + // Plain failure only — the persisted wrapper fields are the service's to mint. + expect(failure).not.toHaveProperty('failureId') + expect(failure).not.toHaveProperty('version') + expect(failure).not.toHaveProperty('intent') + expect(failure).not.toHaveProperty('occurredAt') + }) + + it('returns null for a resolvable (enabled) agent so dispatch proceeds', async () => { + const store = await createStore() + store.addRepo(REPO) + const runtime = new OrcaRuntimeService(store) + + expect(runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1')).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-bulk-forget.test.ts b/src/main/runtime/orca-runtime-bulk-forget.test.ts new file mode 100644 index 00000000000..b7195f6cddb --- /dev/null +++ b/src/main/runtime/orca-runtime-bulk-forget.test.ts @@ -0,0 +1,173 @@ +// Same-principal bulk forget (§U9 ledger #15 / plan :498): the sibling enumeration +// spans exactly one disconnected remote host, excludes live/other-host/not-stranded +// launches, and STRUCTURALLY excludes automation/orchestration/background-owned +// launches even for the same principal. The bulk mutation forgets each eligible +// sibling through the single-forget reconciler and counts only settled forgets. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { AdmissionCapacityRow } from '../agent-launch/agent-launch-admission-store' +import type { ForgetUnknownAgentLaunchResult } from '../../shared/agent-launch-worktree-recovery' + +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') } +})) + +const capacityMock = vi.fn<(principal: unknown) => AdmissionCapacityRow[]>() + +vi.mock('../agent-launch/agent-launch-boundary-host', () => ({ + getHostAgentLaunchBoundary: () => ({ capacitySummaryFor: capacityMock }) +})) + +vi.mock('../agent-launch/agent-launch-operation-store-host', () => ({ + getHostAgentLaunchOperationStore: () => ({ + // Every enumerated sibling has a pending snapshot naming its operation id. + findPendingByScope: (scope: string) => ({ operationId: `op-${scope}`, launchToken: `tok-${scope}` }) + }) +})) + +function row(over: Partial): AdmissionCapacityRow { + return { + intent: 'interactive', + scope: 'wt-1', + admittedAt: 1, + launchToken: 'tok', + baseHarness: 'codex', + executionHostId: 'ssh:prod', + ...over + } +} + +// Rows shared by the enumeration tests: one anchor + siblings that each exercise +// one exclusion rule. `strandedScopes` marks which scopes carry a durable +// launch_state_unknown failure in worktree meta. +function scenarioRows(): AdmissionCapacityRow[] { + return [ + row({ scope: 'wt-anchor', launchToken: 'tok-anchor' }), + row({ scope: 'wt-sib1', launchToken: 'tok-sib1' }), + row({ scope: 'wt-sib2', intent: 'cli', launchToken: 'tok-sib2' }), + // Structural exclusion — same principal + host + stranded, but automation-owned. + row({ scope: 'run-auto', intent: 'automation', launchToken: 'tok-auto' }), + // Different disconnected host. + row({ scope: 'wt-other', executionHostId: 'ssh:other', launchToken: 'tok-other' }), + // Live terminal owns the token -> not stranded. + row({ scope: 'wt-live', launchToken: 'tok-live' }), + // Held reservation but no launch_state_unknown failure yet. + row({ scope: 'wt-notstranded', launchToken: 'tok-fresh' }) + ] +} + +function stubRuntime( + rows: AdmissionCapacityRow[], + opts: { liveTokens?: string[]; notStranded?: string[] } = {} +): OrcaRuntimeService { + capacityMock.mockReturnValue(rows) + const runtime = new OrcaRuntimeService() + const notStranded = new Set(opts.notStranded ?? ['wt-notstranded']) + const internals = runtime as unknown as { + store: unknown + ptysById: Map + listResolvedWorktrees: () => Promise<{ id: string }[]> + } + internals.store = { + // No wt-* selector is a registered repo id; the selector guard probes this. + getRepo: () => undefined, + getWorktreeMeta: (id: string) => + id.startsWith('wt-') && !notStranded.has(id) + ? { agentLaunchFailure: { code: 'launch_state_unknown' } } + : id.startsWith('wt-') + ? {} + : undefined + } + internals.ptysById = new Map( + (opts.liveTokens ?? ['tok-live']).map((t, i) => [`pty-${i}`, { launchToken: t }]) + ) + // resolveWorktreeSelector('id:') matches by id; every wt-* scope resolves. + internals.listResolvedWorktrees = async () => + rows.filter((r) => r.scope.startsWith('wt-')).map((r) => ({ id: r.scope })) + return runtime +} + +describe('unknownWorktreeAgentLaunchSiblingCount', () => { + it('counts only same-host, stranded, worktree-owned siblings (excludes the anchor)', async () => { + const runtime = stubRuntime(scenarioRows()) + const count = await runtime.unknownWorktreeAgentLaunchSiblingCount('id:wt-anchor', undefined) + // wt-sib1 + wt-sib2 only. wt-other (other host), wt-live (live), wt-notstranded + // (no failure), and run-auto (automation) are all excluded. + expect(count).toBe(2) + }) + + it('makes a same-principal automation-owned stranded launch INVISIBLE (ledger #15)', async () => { + // Only the anchor and a same-host, stranded, automation-owned row exist. + const runtime = stubRuntime([ + row({ scope: 'wt-anchor', launchToken: 'tok-anchor' }), + row({ scope: 'wt-auto-owned', intent: 'automation', launchToken: 'tok-auto' }) + ]) + const count = await runtime.unknownWorktreeAgentLaunchSiblingCount('id:wt-anchor', undefined) + // The automation-owned launch is stranded on the same host under the same + // principal, yet the intent filter in the enumeration hides it entirely. + expect(count).toBe(0) + }) + + it('returns 0 for a LOCAL anchor — bulk forget only spans a disconnected remote host', async () => { + const runtime = stubRuntime([ + row({ scope: 'wt-anchor', executionHostId: 'local', launchToken: 'tok-anchor' }), + row({ scope: 'wt-sib1', executionHostId: 'local', launchToken: 'tok-sib1' }) + ]) + const count = await runtime.unknownWorktreeAgentLaunchSiblingCount('id:wt-anchor', undefined) + expect(count).toBe(0) + }) + + it('scopes the principal from clientKind, never client JSON', async () => { + const runtime = stubRuntime(scenarioRows()) + await runtime.unknownWorktreeAgentLaunchSiblingCount('id:wt-anchor', 'mobile') + expect(capacityMock).toHaveBeenCalledWith({ kind: 'remote', id: 'mobile' }) + }) +}) + +describe('forgetUnknownWorktreeAgentLaunchSiblings', () => { + it('forgets exactly the eligible siblings and counts only settled forgets', async () => { + const runtime = stubRuntime(scenarioRows()) + const forgotten: string[] = [] + const internals = runtime as unknown as { + forgetUnknownWorktreeAgentLaunch: ( + selector: string, + args: { expectedOperationId: string; clientMutationId: string } + ) => Promise + } + // Spy on the single-forget reconciler (tested exhaustively elsewhere): record the + // selectors, and make one sibling self-reject to prove counting is by outcome. + internals.forgetUnknownWorktreeAgentLaunch = async (selector, args) => { + forgotten.push(selector) + expect(args.expectedOperationId).toBe(`op-${selector.slice(3)}`) + return selector === 'id:wt-sib2' + ? { status: 'rejected', requestError: { code: 'stale_agent_launch_failure' } } + : { status: 'forgotten' } + } + + const result = await runtime.forgetUnknownWorktreeAgentLaunchSiblings('id:wt-anchor', undefined) + + expect(forgotten).toEqual(['id:wt-sib1', 'id:wt-sib2']) + // wt-sib1 forgot; wt-sib2 self-rejected -> only 1 counted. + expect(result.forgottenCount).toBe(1) + }) + + it('forgets nothing for a LOCAL anchor', async () => { + const runtime = stubRuntime([ + row({ scope: 'wt-anchor', executionHostId: 'local', launchToken: 'tok-anchor' }), + row({ scope: 'wt-sib1', executionHostId: 'local', launchToken: 'tok-sib1' }) + ]) + const internals = runtime as unknown as { + forgetUnknownWorktreeAgentLaunch: () => Promise + } + const spy = vi.fn(async (): Promise => ({ status: 'forgotten' })) + internals.forgetUnknownWorktreeAgentLaunch = spy + + const result = await runtime.forgetUnknownWorktreeAgentLaunchSiblings('id:wt-anchor', undefined) + + expect(spy).not.toHaveBeenCalled() + expect(result.forgottenCount).toBe(0) + }) +}) diff --git a/src/main/runtime/orca-runtime-dispatch-identity.test.ts b/src/main/runtime/orca-runtime-dispatch-identity.test.ts new file mode 100644 index 00000000000..b06fe449b8b --- /dev/null +++ b/src/main/runtime/orca-runtime-dispatch-identity.test.ts @@ -0,0 +1,73 @@ +// W-T1 (§U9, ledger #9): the orchestration coordinator resolves a dispatch's +// agent identity from the terminal that ACTUALLY receives the work (Option B, +// target-terminal attribution). These are the activation tests: a real launch +// attribution resolves to the requested (possibly custom) identity, a hook-only +// terminal is its own requested agent, and an unattributed target returns null +// so the coordinator skips validation instead of guessing. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +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') } +})) + +const CUSTOM_CODEX_ID = 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd' +const SETTINGS = { + customTuiAgents: [{ id: CUSTOM_CODEX_ID, baseAgent: 'codex' }], + deletedCustomTuiAgents: [] +} + +type DispatchIdentityInternals = { + store: { getSettings: () => unknown } + handles: Map + ptysById: Map + resolveDispatchAgentIdentityForHandle: ( + handle: string + ) => { requestedAgent: string; baseAgent: string | null } | null +} + +function makeRuntime( + ptys: Record +): DispatchIdentityInternals { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as DispatchIdentityInternals + internals.store = { getSettings: () => SETTINGS } + internals.handles = new Map( + Object.keys(ptys).map((ptyId) => [`term_${ptyId}`, { ptyId }]) + ) + internals.ptysById = new Map(Object.entries(ptys)) + return internals +} + +describe('resolveDispatchAgentIdentityForHandle (W-T1 Option B)', () => { + it('returns the true requested custom identity + base for a launch-attributed target', () => { + const internals = makeRuntime({ p1: { launchAgent: CUSTOM_CODEX_ID } }) + expect(internals.resolveDispatchAgentIdentityForHandle('term_p1')).toEqual({ + requestedAgent: CUSTOM_CODEX_ID, + baseAgent: 'codex' + }) + }) + + it('treats a hook-only base attribution as its own requested agent', () => { + // No launchAgent: the terminal is attributed to a built-in base via hook + // metadata alone. A built-in id is its own requested agent (ledger #9). + const internals = makeRuntime({ p2: { foregroundAgent: 'claude' } }) + expect(internals.resolveDispatchAgentIdentityForHandle('term_p2')).toEqual({ + requestedAgent: 'claude', + baseAgent: 'claude' + }) + }) + + it('returns null for an unattributed target so validation is skipped, never guessed', () => { + const internals = makeRuntime({ p3: {} }) + expect(internals.resolveDispatchAgentIdentityForHandle('term_p3')).toBeNull() + }) + + it('returns null for an unknown handle', () => { + const internals = makeRuntime({ p1: { launchAgent: CUSTOM_CODEX_ID } }) + expect(internals.resolveDispatchAgentIdentityForHandle('term_missing')).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-launch-notice-dismissal.test.ts b/src/main/runtime/orca-runtime-launch-notice-dismissal.test.ts new file mode 100644 index 00000000000..81b0bfd2de1 --- /dev/null +++ b/src/main/runtime/orca-runtime-launch-notice-dismissal.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { WorkspaceSessionState, TerminalTab, TerminalLayoutSnapshot } from '../../shared/types' +import type { PersistedLaunchNoticeState } from '../../shared/agent-launch-contract' + +const WORKTREE_ID = 'wt-notice' +const TAB_ID = 'tab-notice' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TOKEN = 'launch-token-1' + +function noticeState(): PersistedLaunchNoticeState { + return { + launchToken: TOKEN, + notices: [ + { code: 'disabled_custom_fallback', label: 'My Claude', baseAgent: 'claude' }, + { code: 'env_withheld', label: 'My Claude' } + ] + } +} + +function makeSession(launchNotices: PersistedLaunchNoticeState | undefined): WorkspaceSessionState { + const layout: TerminalLayoutSnapshot = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-notice' } + } + const tab: TerminalTab = { + id: TAB_ID, + ptyId: 'pty-notice', + worktreeId: WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...(launchNotices ? { launchNotices } : {}) + } + return { + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: TAB_ID, + tabsByWorktree: { [WORKTREE_ID]: [tab] }, + terminalLayoutsByTabId: { [TAB_ID]: layout } + } +} + +function makeRuntime(initial: WorkspaceSessionState): { + runtime: OrcaRuntimeService + setWorkspaceSession: ReturnType + getSession: () => WorkspaceSessionState +} { + let session = initial + const setWorkspaceSession = vi.fn((next: WorkspaceSessionState) => { + session = next + }) + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession, + getSettings: () => ({}) as never, + // No test selector is a registered repo id; the selector guard probes this. + getRepo: () => undefined + } + return { + runtime: new OrcaRuntimeService(store as never), + setWorkspaceSession, + getSession: () => session + } +} + +function launchNoticesOf(session: WorkspaceSessionState): PersistedLaunchNoticeState | undefined { + return session.tabsByWorktree[WORKTREE_ID][0].launchNotices +} + +describe('OrcaRuntimeService.dismissLaunchNotice', () => { + it('removes the matching code, persists once, and keeps other codes', async () => { + const { runtime, setWorkspaceSession, getSession } = makeRuntime(makeSession(noticeState())) + + const result = await runtime.dismissLaunchNotice(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + launchToken: TOKEN, + code: 'disabled_custom_fallback' + }) + + expect(result).toEqual({ ok: true, changed: true }) + expect(setWorkspaceSession).toHaveBeenCalledTimes(1) + expect(launchNoticesOf(getSession())).toEqual({ + launchToken: TOKEN, + notices: [{ code: 'env_withheld', label: 'My Claude' }] + }) + }) + + it('drops launchNotices entirely once the last code is dismissed', async () => { + const { runtime, getSession } = makeRuntime( + makeSession({ launchToken: TOKEN, notices: [{ code: 'env_withheld', label: 'My Claude' }] }) + ) + + const result = await runtime.dismissLaunchNotice(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + launchToken: TOKEN, + code: 'env_withheld' + }) + + expect(result).toEqual({ ok: true, changed: true }) + expect(launchNoticesOf(getSession())).toBeUndefined() + }) + + it('is idempotent on repeat: no second write when the code is already gone', async () => { + const { runtime, setWorkspaceSession } = makeRuntime(makeSession(noticeState())) + + await runtime.dismissLaunchNotice(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + launchToken: TOKEN, + code: 'disabled_custom_fallback' + }) + setWorkspaceSession.mockClear() + + const second = await runtime.dismissLaunchNotice(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + launchToken: TOKEN, + code: 'disabled_custom_fallback' + }) + + expect(second).toEqual({ ok: true, changed: false }) + expect(setWorkspaceSession).not.toHaveBeenCalled() + }) + + it('fails closed on a foreign token without mutating', async () => { + const { runtime, setWorkspaceSession, getSession } = makeRuntime(makeSession(noticeState())) + + const result = await runtime.dismissLaunchNotice(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + launchToken: 'not-the-token', + code: 'disabled_custom_fallback' + }) + + expect(result).toEqual({ ok: false, changed: false }) + expect(setWorkspaceSession).not.toHaveBeenCalled() + expect(launchNoticesOf(getSession())?.notices).toHaveLength(2) + }) + + it('surfaces notices to the mobile snapshot and a connected client observes the dismissal once', async () => { + const { runtime } = makeRuntime(makeSession(noticeState())) + + const initial = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const seededTab = initial.tabs.find((tab) => tab.type === 'terminal') + expect(seededTab && 'launchNotices' in seededTab ? seededTab.launchNotices : undefined).toEqual( + noticeState() + ) + + const observed: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((result) => { + const tab = result.tabs.find((candidate) => candidate.type === 'terminal') + const notices = + tab && 'launchNotices' in tab + ? (tab.launchNotices as PersistedLaunchNoticeState) + : undefined + observed.push(notices?.notices.length ?? 0) + }) + + await runtime.dismissLaunchNotice(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + launchToken: TOKEN, + code: 'disabled_custom_fallback' + }) + unsubscribe() + + expect(observed).toEqual([1]) + }) +}) diff --git a/src/main/runtime/orca-runtime-pending-summary.test.ts b/src/main/runtime/orca-runtime-pending-summary.test.ts new file mode 100644 index 00000000000..49e7dab7a90 --- /dev/null +++ b/src/main/runtime/orca-runtime-pending-summary.test.ts @@ -0,0 +1,109 @@ +// Runtime wiring for the capacity-recovery pending-summary RPC: derives the +// admission principal from clientKind (own rows only), computes liveness from the +// in-process pty registry (local no-match => absent, remote no-match => unknown), +// resolves the worktree deep link from the store, and never projects the token. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { AdmissionCapacityRow } from '../agent-launch/agent-launch-admission-store' + +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') } +})) + +const capacityMock = vi.fn<(principal: unknown) => AdmissionCapacityRow[]>() + +vi.mock('../agent-launch/agent-launch-boundary-host', () => ({ + getHostAgentLaunchBoundary: () => ({ capacitySummaryFor: capacityMock }) +})) + +function stubRuntime(rows: AdmissionCapacityRow[], liveTokens: string[]): OrcaRuntimeService { + capacityMock.mockReturnValue(rows) + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as { + store: unknown + ptysById: Map + } + internals.store = { + // Only wt-* scopes name a real worktree; a folder-workspace scope resolves none. + getWorktreeMeta: (id: string) => (id.startsWith('wt-') ? {} : undefined), + getSshTarget: (id: string) => (id === 'prod' ? { label: 'Prod box' } : undefined) + } + internals.ptysById = new Map(liveTokens.map((t, i) => [`pty-${i}`, { launchToken: t }])) + return runtime +} + +function row(over: Partial): AdmissionCapacityRow { + return { + intent: 'cli', + scope: 'wt-1', + admittedAt: 1, + launchToken: 'tok', + baseHarness: 'codex', + executionHostId: 'local', + ...over + } +} + +describe('pendingAgentLaunchSummary', () => { + it('derives the admission principal from clientKind (own rows only)', () => { + const runtime = stubRuntime([], []) + runtime.pendingAgentLaunchSummary(undefined) + expect(capacityMock).toHaveBeenLastCalledWith({ kind: 'local' }) + runtime.pendingAgentLaunchSummary('mobile') + expect(capacityMock).toHaveBeenLastCalledWith({ kind: 'remote', id: 'mobile' }) + }) + + it('computes liveness, worktree deep links, and ssh host label without leaking the token', () => { + const runtime = stubRuntime( + [ + row({ + scope: 'wt-live', + launchToken: 'tok-live', + executionHostId: 'local', + admittedAt: 10 + }), + row({ + intent: 'interactive', + scope: 'wt-remote', + launchToken: 'tok-gone', + executionHostId: 'ssh:prod', + baseHarness: 'claude', + admittedAt: 20 + }), + row({ + scope: 'folder-x', + launchToken: 'tok-local-gone', + executionHostId: 'local', + admittedAt: 30 + }) + ], + ['tok-live'] + ) + const summary = runtime.pendingAgentLaunchSummary(undefined) + + // Token-matched -> live, worktree deep link, local host label. + expect(summary.rows[0]).toMatchObject({ + sourceKind: 'cli', + baseHarness: 'codex', + liveness: 'live', + deepLink: { kind: 'worktree', worktreeId: 'wt-live' } + }) + // Remote, no token match -> unknown (never a false absent); ssh alias label. + expect(summary.rows[1]).toMatchObject({ + liveness: 'unknown', + targetHostDisplayName: 'Prod box', + deepLink: { kind: 'worktree', worktreeId: 'wt-remote' } + }) + // Local, no token match -> absent; folder scope names no worktree -> no link. + expect(summary.rows[2].liveness).toBe('absent') + expect(summary.rows[2]).not.toHaveProperty('deepLink') + + const text = JSON.stringify(summary) + for (const token of ['tok-live', 'tok-gone', 'tok-local-gone']) { + expect(text).not.toContain(token) + } + }) +}) diff --git a/src/main/runtime/orca-runtime-registry-safety.test.ts b/src/main/runtime/orca-runtime-registry-safety.test.ts new file mode 100644 index 00000000000..48d23d552cc --- /dev/null +++ b/src/main/runtime/orca-runtime-registry-safety.test.ts @@ -0,0 +1,105 @@ +// W1 oracle-16 rider: a custom agent id must flow through the runtime's built-in- +// keyed registry sites via the base accessor (resolveAgentConfigForRegistry), not +// index the static config map directly. tsc cannot verify this (noImplicitAny is +// off in the toolkit tsconfig, so a custom id silently yields `any`/undefined), so +// these tests are the oracle: a custom codex id resolves to its base's config and +// takes the codex trust/draft path — it neither throws nor silently defaults. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +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') } +})) + +const markCodexProjectTrusted = vi.fn() +const markCopilotFolderTrusted = vi.fn() +const markCursorWorkspaceTrusted = vi.fn() +vi.mock('../agent-trust-presets', () => ({ + markCodexProjectTrusted: (path: string) => markCodexProjectTrusted(path), + markCopilotFolderTrusted: (path: string) => markCopilotFolderTrusted(path), + markCursorWorkspaceTrusted: (path: string) => markCursorWorkspaceTrusted(path) +})) + +const markRemoteAgentWorkspaceTrusted = vi.fn(async (_args: unknown) => {}) +vi.mock('../remote-agent-trust-presets', () => ({ + markRemoteAgentWorkspaceTrusted: (args: unknown) => markRemoteAgentWorkspaceTrusted(args) +})) + +const createDraftPasteReadyScanner = vi.fn((_signal: string) => ({ + observe: () => ({ ready: true }) +})) +vi.mock('../../shared/draft-paste-ready-scanner', () => ({ + createDraftPasteReadyScanner: (signal: string) => createDraftPasteReadyScanner(signal) +})) + +// A live custom codex agent — resolves to base `codex`, whose config carries +// preflightTrust:'codex' and draftPasteReadySignal:'codex-composer-prompt'. +const CUSTOM_CODEX_ID = 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd' +const SETTINGS = { + customTuiAgents: [{ id: CUSTOM_CODEX_ID, baseAgent: 'codex' }], + deletedCustomTuiAgents: [] +} + +type RegistrySafetyInternals = { + store: { getSettings: () => unknown } + markLocalWorkspaceTrustedForAgent: (agent: string, workspacePath: string) => void + markRemoteWorkspaceTrustedForAgent: ( + agent: string, + connectionId: string, + workspacePath: string + ) => Promise + waitForStartupDraftReady: (handle: string, agent: string) => Promise + getLivePtyForHandle: (handle: string) => unknown + subscribeToTerminalData: (ptyId: string, cb: (data: string) => void) => () => void + recentPtyOutputById: Map +} + +function makeRuntime(): RegistrySafetyInternals { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as RegistrySafetyInternals + internals.store = { getSettings: () => SETTINGS } + return internals +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('runtime registry safety (oracle 16): custom id resolves via base', () => { + it('markLocalWorkspaceTrustedForAgent takes the codex trust path, not a crash or default', () => { + const internals = makeRuntime() + expect(() => + internals.markLocalWorkspaceTrustedForAgent(CUSTOM_CODEX_ID, '/ws/custom-codex') + ).not.toThrow() + expect(markCodexProjectTrusted).toHaveBeenCalledWith('/ws/custom-codex') + expect(markCursorWorkspaceTrusted).not.toHaveBeenCalled() + expect(markCopilotFolderTrusted).not.toHaveBeenCalled() + }) + + it('markRemoteWorkspaceTrustedForAgent resolves the codex preset from the base', async () => { + const internals = makeRuntime() + await expect( + internals.markRemoteWorkspaceTrustedForAgent(CUSTOM_CODEX_ID, 'conn-1', '/ws/remote-codex') + ).resolves.toBeUndefined() + expect(markRemoteAgentWorkspaceTrusted).toHaveBeenCalledWith({ + preset: 'codex', + connectionId: 'conn-1', + workspacePath: '/ws/remote-codex' + }) + }) + + it('waitForStartupDraftReady picks the base draft signal, not the silent default', async () => { + const internals = makeRuntime() + internals.getLivePtyForHandle = () => ({ pty: { ptyId: 'p1' } }) + internals.subscribeToTerminalData = () => () => {} + internals.recentPtyOutputById = new Map([['p1', 'ready-bytes']]) + await expect( + internals.waitForStartupDraftReady('term-1', CUSTOM_CODEX_ID) + ).resolves.toBe('p1') + // Base-resolved codex signal — NOT the render-quiet-after-bracketed-paste default. + expect(createDraftPasteReadyScanner).toHaveBeenCalledWith('codex-composer-prompt') + }) +}) diff --git a/src/main/runtime/orca-runtime-revoked-forget.test.ts b/src/main/runtime/orca-runtime-revoked-forget.test.ts new file mode 100644 index 00000000000..accda5aebb2 --- /dev/null +++ b/src/main/runtime/orca-runtime-revoked-forget.test.ts @@ -0,0 +1,200 @@ +// Revoked-principal forget override (§U9 / plan :498): the LOCAL desktop owner may +// forget a stranded worktree launch ONLY when its owning remote principal is +// EXPLICITLY REVOKED — no paired device of that scope remains in the pairing store +// (checked against the store, not connection liveness) — and it owns the row on a +// disconnected remote provider. It routes through the same single-forget reconciler +// as the owner-facing forget, and can never clear an active paired device's, the +// local host's, or an unowned reservation. The reconciler is mocked here (its guards +// are exercised in agent-launch-worktree-forget's own suite); these tests pin the +// added REVOCATION gate and the principal the forget is namespaced under. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { agentLaunchIdempotencyKey } from '../agent-launch/agent-launch-operation-store' +import type { AdmissionCapacityRow } from '../agent-launch/agent-launch-admission-store' +import type { ForgetUnknownAgentLaunchDeps } from '../agent-launch/agent-launch-worktree-forget' +import type { DeviceScope } from '../../shared/runtime-types' + +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') } +})) + +const capacityMock = vi.fn<(principal: unknown) => AdmissionCapacityRow[]>() + +vi.mock('../agent-launch/agent-launch-boundary-host', () => ({ + getHostAgentLaunchBoundary: () => ({ capacitySummaryFor: capacityMock }) +})) + +// Spy the shared reconciler: capture its injected deps + params without running the +// real settle so these tests isolate the override's revocation gate. +const forgetSpy = vi.fn( + (_deps: ForgetUnknownAgentLaunchDeps, _params: unknown) => + ({ status: 'forgotten' }) as const +) +vi.mock('../agent-launch/agent-launch-worktree-forget', () => ({ + runForgetUnknownAgentLaunch: (deps: ForgetUnknownAgentLaunchDeps, params: unknown) => + forgetSpy(deps, params) +})) + +vi.mock('../agent-launch/agent-launch-operation-store-host', () => ({ + getHostAgentLaunchOperationStore: () => ({ findPendingByScope: () => null }) +})) + +function row(over: Partial): AdmissionCapacityRow { + return { + intent: 'interactive', + scope: 'wt-1', + admittedAt: 1, + launchToken: 'tok', + baseHarness: 'codex', + executionHostId: 'ssh:prod', + ...over + } +} + +function stubRuntime( + rows: AdmissionCapacityRow[], + pairedScopes: DeviceScope[] +): OrcaRuntimeService { + capacityMock.mockReturnValue(rows) + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as { + store: unknown + getPairedDeviceScopesFn: () => readonly DeviceScope[] + notifyWorktreesChanged: (repoId: string) => void + listResolvedWorktrees: () => Promise<{ id: string; repoId: string; path: string }[]> + } + internals.store = { + // Only the fixture repo id resolves; a truthy return for a bare worktree id + // would trip the repo-id-collision selector guard. + getRepo: (id: string) => (id === 'repo-1' ? { id } : undefined), + getWorktreeMeta: () => ({ agentLaunchFailure: { code: 'launch_state_unknown' } }), + setWorktreeMeta: () => {} + } + internals.getPairedDeviceScopesFn = () => pairedScopes + internals.notifyWorktreesChanged = () => {} + // Every scope in the capacity rows (plus an orphan) resolves to a worktree. + internals.listResolvedWorktrees = async () => + [...rows.map((r) => r.scope), 'wt-orphan'].map((scope) => ({ + id: scope, + repoId: 'repo-1', + path: `/wt/${scope}` + })) + return runtime +} + +const FORGET_ARGS = { expectedOperationId: 'op-1', clientMutationId: 'cmid-1' } + +describe('forgetRevokedRemoteWorktreeAgentLaunch', () => { + beforeEach(() => { + forgetSpy.mockClear() + // Drop any per-test mockImplementation; stubRuntime re-sets the return value. + capacityMock.mockReset() + }) + + it('forgets a row whose owning mobile principal is revoked, under that principal', async () => { + // mobile has no paired device (revoked); runtime is still paired. + const runtime = stubRuntime([row({ scope: 'wt-mobile' })], ['runtime']) + + const result = await runtime.forgetRevokedRemoteWorktreeAgentLaunch('id:wt-mobile', FORGET_ARGS) + + expect(result).toEqual({ status: 'forgotten' }) + const [deps, params] = forgetSpy.mock.calls[0] + expect(params).toMatchObject({ + scope: 'wt-mobile', + expectedOperationId: 'op-1', + clientMutationId: 'cmid-1' + }) + // The forget is namespaced under the REVOKED remote principal, not the local caller. + expect(deps.idempotencyKeyFor('probe')).toBe( + agentLaunchIdempotencyKey({ + principal: { kind: 'remote', id: 'mobile' }, + scope: 'wt-mobile', + clientMutationId: 'probe' + }) + ) + }) + + it('never forgets an ACTIVE paired device’s row (revocation gate, not liveness)', async () => { + // Both kinds still paired: the row is disconnected but its owner is NOT revoked. + const runtime = stubRuntime([row({ scope: 'wt-mobile' })], ['mobile', 'runtime']) + + const result = await runtime.forgetRevokedRemoteWorktreeAgentLaunch('id:wt-mobile', FORGET_ARGS) + + expect(result).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + expect(forgetSpy).not.toHaveBeenCalled() + }) + + it('never forgets a LOCAL-host reservation, even with all remotes revoked', async () => { + const runtime = stubRuntime([row({ scope: 'wt-local', executionHostId: 'local' })], []) + + const result = await runtime.forgetRevokedRemoteWorktreeAgentLaunch('id:wt-local', FORGET_ARGS) + + expect(result.status).toBe('rejected') + expect(forgetSpy).not.toHaveBeenCalled() + }) + + it('selects the correct revoked kind and is not shadowed by a still-paired kind', async () => { + // mobile still paired; runtime revoked and owns the row. + const runtime = stubRuntime([row({ scope: 'wt-runtime' })], ['mobile']) + + const result = await runtime.forgetRevokedRemoteWorktreeAgentLaunch('id:wt-runtime', FORGET_ARGS) + + expect(result).toEqual({ status: 'forgotten' }) + const [deps] = forgetSpy.mock.calls[0] + expect(deps.idempotencyKeyFor('probe')).toBe( + agentLaunchIdempotencyKey({ + principal: { kind: 'remote', id: 'runtime' }, + scope: 'wt-runtime', + clientMutationId: 'probe' + }) + ) + }) + + it('re-gates revocation on EVERY row: a device reconnecting mid-sequence blocks the next row', async () => { + // Two stranded mobile-owned rows; the paired set is read live per call. + const rows = [row({ scope: 'wt-row1' }), row({ scope: 'wt-row2' })] + const runtime = stubRuntime(rows, []) + let pairedNow: DeviceScope[] = [] + const internals = runtime as unknown as { + getPairedDeviceScopesFn: () => readonly DeviceScope[] + } + internals.getPairedDeviceScopesFn = () => pairedNow + // Faithful ownership: both rows belong to the mobile principal only, so the + // runtime principal's capacity view is empty (unlike the shared-rows default). + capacityMock.mockImplementation((principal) => + (principal as { id?: string }).id === 'mobile' ? rows : [] + ) + + // Row 1: mobile revoked (no paired device) -> forgotten. + const first = await runtime.forgetRevokedRemoteWorktreeAgentLaunch('id:wt-row1', FORGET_ARGS) + expect(first).toEqual({ status: 'forgotten' }) + + // A mobile device reconnects between rows: the principal is no longer revoked. + pairedNow = ['mobile'] + + // Row 2: the SAME sequence, but the live re-gate now blocks it (not a stale + // dialog-open snapshot) -> rejected, and the reconciler runs only for row 1. + const second = await runtime.forgetRevokedRemoteWorktreeAgentLaunch('id:wt-row2', FORGET_ARGS) + expect(second).toEqual({ + status: 'rejected', + requestError: { code: 'stale_agent_launch_failure' } + }) + expect(forgetSpy).toHaveBeenCalledTimes(1) + }) + + it('rejects when no revoked remote principal owns the row', async () => { + // All remotes revoked, but the addressed worktree appears in no capacity row. + const runtime = stubRuntime([row({ scope: 'wt-mobile' })], []) + + const result = await runtime.forgetRevokedRemoteWorktreeAgentLaunch('id:wt-orphan', FORGET_ARGS) + + expect(result.status).toBe('rejected') + expect(forgetSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orchestration/coordinator-launch-validation.test.ts b/src/main/runtime/orchestration/coordinator-launch-validation.test.ts new file mode 100644 index 00000000000..46e6562c1dd --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-launch-validation.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' +import { + Coordinator, + type CoordinatorRuntime, + type DispatchAgentIdentity, + type DispatchAgentLaunchValidation +} from './coordinator' +import type { PersistedAgentLaunchFailure } from '../../../shared/agent-launch-contract' + +// (b)-lite orchestration seam (§U6): the coordinator resolve-only re-validates a +// dispatch's host-validated identity before injecting a worker prompt. Production +// dispatches carry a null identity (no source until U9), so validation is inert; +// these tests inject an identity + a validation outcome to prove the failure path +// fails the dispatch through the existing retry/circuit-break machinery WITHOUT +// creating a terminal or injecting a prompt (zero PTY on known failure). + +type ValidationAwareRuntime = CoordinatorRuntime & { + sentMessages: { handle: string; text: string }[] + createdTerminals: string[] + validateCalls: DispatchAgentIdentity[] + terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[] +} + +function createRuntime(validation: DispatchAgentLaunchValidation | null): ValidationAwareRuntime { + const mock: ValidationAwareRuntime = { + sentMessages: [], + createdTerminals: [], + validateCalls: [], + terminals: [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }], + async sendTerminalAgentPrompt(handle: string, text: string) { + mock.sentMessages.push({ handle, text }) + return { handle, accepted: true } + }, + async listTerminals() { + return { terminals: mock.terminals } + }, + async createTerminal(_worktree?: string, opts?: { title?: string }) { + const handle = `term_worker_${mock.createdTerminals.length}` + mock.createdTerminals.push(handle) + mock.terminals.push({ handle, worktreeId: 'wt1', connected: true, writable: true }) + return { handle, worktreeId: 'wt1', title: opts?.title ?? '' } + }, + async waitForTerminal(handle: string) { + return { handle, condition: 'exit' } + }, + async probeWorktreeDrift() { + return null + }, + ...(validation + ? { + async validateDispatchAgentLaunch(identity: DispatchAgentIdentity) { + mock.validateCalls.push(identity) + return validation + } + } + : {}) + } + return mock +} + +function insertWorkerDone(db: OrchestrationDb, taskId: string, from: string): void { + const dispatch = db.getDispatchContext(taskId) + db.insertMessage({ + from, + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId: dispatch?.id, outcome: 'succeeded' }) + }) +} + +const failure: PersistedAgentLaunchFailure = { + code: 'base_agent_disabled', + version: 1, + failureId: 'fail-orchestration-1', + intent: 'orchestration', + occurredAt: 1_700_000_000_000 +} + +describe('coordinator dispatch launch validation', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('never validates when no identity source is configured (production no-op)', async () => { + db = new OrchestrationDb(':memory:') + // Runtime exposes the validation callback, but no resolveDispatchIdentity is + // configured, so every dispatch identity is null and validation is skipped. + const runtime = createRuntime({ ok: false, error: 'unused', launchFailure: failure }) + const task = db.createTask({ spec: 'ship it' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20 + }) + const runPromise = coordinator.run() + await new Promise((r) => setTimeout(r, 60)) + insertWorkerDone(db, task.id, 'term_a') + const result = await runPromise + + expect(runtime.validateCalls).toHaveLength(0) + expect(runtime.sentMessages.length).toBeGreaterThan(0) + expect(result.status).toBe('completed') + expect(db.getDispatchContext(task.id)?.requested_agent).toBeNull() + }) + + it('fails a non-null identity dispatch through failDispatch with zero PTY', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createRuntime({ ok: false, error: 'agent disabled', launchFailure: failure }) + const task = db.createTask({ spec: 'ship it' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 10, + resolveDispatchIdentity: () => ({ requestedAgent: 'ghost-agent', baseAgent: 'claude' }) + }) + // The dispatch keeps failing validation until the circuit breaker trips, at + // which point the task is 'failed' and the run converges without any prompt. + const result = await coordinator.run() + + expect(result.status).toBe('failed') + // Zero PTY: no worker prompt injected and no worker terminal created — the + // pre-provided idle terminal is reused across every failing retry. + expect(runtime.sentMessages).toHaveLength(0) + expect(runtime.createdTerminals).toHaveLength(0) + expect(runtime.validateCalls.length).toBeGreaterThan(0) + + const dispatch = db.getDispatchContext(task.id) + expect(dispatch?.status).toBe('circuit_broken') + expect(dispatch?.failure_count).toBe(3) + expect(dispatch?.requested_agent).toBe('ghost-agent') + expect(JSON.parse(dispatch?.agent_launch_failure ?? '{}').code).toBe('base_agent_disabled') + expect(db.getTask(task.id)?.status).toBe('failed') + }) + + it('dispatches normally when a non-null identity validates', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createRuntime({ ok: true }) + const task = db.createTask({ spec: 'ship it' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20, + resolveDispatchIdentity: () => ({ requestedAgent: 'my-claude', baseAgent: 'claude' }) + }) + const runPromise = coordinator.run() + await new Promise((r) => setTimeout(r, 60)) + insertWorkerDone(db, task.id, 'term_a') + const result = await runPromise + + expect(runtime.validateCalls).toEqual([{ requestedAgent: 'my-claude', baseAgent: 'claude' }]) + expect(runtime.sentMessages.length).toBeGreaterThan(0) + expect(result.status).toBe('completed') + expect(db.getDispatchContext(task.id)?.requested_agent).toBe('my-claude') + }) + + it('passes the dispatch target handle to the identity resolver (W-T1 Option B)', async () => { + // The resolver reads the attribution of the terminal that actually receives + // the work, so the coordinator must hand it the targetHandle, not just the + // task. The pre-provided idle terminal 'term_a' is the dispatch target. + db = new OrchestrationDb(':memory:') + const runtime = createRuntime({ ok: true }) + const task = db.createTask({ spec: 'ship it' }) + const resolverArgs: { taskId: string; targetHandle: string }[] = [] + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20, + resolveDispatchIdentity: (t, targetHandle) => { + resolverArgs.push({ taskId: t.id, targetHandle }) + return { requestedAgent: 'my-claude', baseAgent: 'claude' } + } + }) + const runPromise = coordinator.run() + await new Promise((r) => setTimeout(r, 60)) + insertWorkerDone(db, task.id, 'term_a') + await runPromise + + expect(resolverArgs).toEqual([{ taskId: task.id, targetHandle: 'term_a' }]) + }) +}) diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index 742434b1b80..b76b25aca4c 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -36,4 +36,11 @@ export type CoordinatorRuntime = { } | null // Why: Windows can host native and WSL workers at once, so the worker pane (not the coordinator) picks the packaged CLI name. getTerminalOrchestrationCliCommand?(handle: string): 'orca' | 'orca-ide' + validateDispatchAgentLaunch?(identity: DispatchAgentIdentity): Promise } +import type { PersistedAgentLaunchFailure } from '../../../shared/agent-launch-contract' + +export type DispatchAgentIdentity = { requestedAgent: string; baseAgent: string | null } +export type DispatchAgentLaunchValidation = + | { ok: true } + | { ok: false; error: string; launchFailure: PersistedAgentLaunchFailure } diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index 685552cc2e9..3acbddaf3f2 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -2,15 +2,17 @@ import type { OrchestrationDb } from './db' import type { TaskRow } from './types' import { buildDispatchPreamble } from './preamble' -import type { CoordinatorRuntime, WorktreeDrift } from './coordinator-runtime-contract' +import type { + CoordinatorRuntime, + DispatchAgentIdentity, + WorktreeDrift +} from './coordinator-runtime-contract' import { DISPATCH_STALE_THRESHOLD, parseAllowStaleBaseFromSpec } from './coordinator-stale-base-flag' -import { isAgentPromptStalledError } from '../agent-prompt-submission-verification' -/** `dispatched-unobserved`: the preamble landed but the worker's turn start was never observed. */ -export type TaskDispatchResult = 'dispatched' | 'dispatched-unobserved' | 'stale-base-refused' +export type TaskDispatchResult = 'dispatched' | 'stale-base-refused' // Why: 10 min = documented heartbeat cadence (5 min) × 2, so one missed heartbeat is the earliest a dispatch can look stale. const HUNG_THRESHOLD_MS = 10 * 60 * 1000 @@ -70,7 +72,7 @@ export async function dispatchTaskToWorker(params: { onLog: (msg: string) => void // Why: the coordinator owns the failed-task list, so a circuit break is reported back instead of mutated here. onCircuitBroken: (taskId: string) => void - nestedWorkerMaxDepth: number + resolveDispatchIdentity?: (task: TaskRow, targetHandle: string) => DispatchAgentIdentity | null }): Promise { const { db, runtime, task, targetHandle, baseDrift, onLog } = params // Why (§3.1): drift check runs before createDispatchContext so a refusal doesn't bump failure_count (carried forward as MAX in db.ts:301-306) and burn the circuit-breaker budget; the task stays `ready` and retries next tick. @@ -98,23 +100,32 @@ export async function dispatchTaskToWorker(params: { dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation ? dispatchAuthority.processIncarnation : undefined - const dispatch = db.createDispatchContext({ - taskId: task.id, - assigneeHandle: targetHandle, + const identity = params.resolveDispatchIdentity?.(task, targetHandle) ?? undefined + const dispatch = db.createDispatchContext( + task.id, + targetHandle, assigneePaneKey, - launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, + dispatchAuthority?.launchTokenHash ?? undefined, processIncarnation, - // Why system: the automatic loop is host-local Orca code driven by - // coordinator_runs, not a CLI caller, so it is a root by construction. - creator: { kind: 'system' }, - maxDepth: params.nestedWorkerMaxDepth - }) + identity + ) + + if (identity && runtime.validateDispatchAgentLaunch) { + const validation = await runtime.validateDispatchAgentLaunch(identity) + if (!validation.ok) { + const updated = db.failDispatch(dispatch.id, validation.error, validation.launchFailure) + if (updated?.status === 'circuit_broken') { + params.onCircuitBroken(task.id) + } + onLog(`Dispatch of ${task.id} failed launch validation (${validation.launchFailure.code})`) + return 'dispatched' + } + } // Why: dispatched agents use orca-dev in dev mode to reach the dev runtime's socket, not production (Section 6.4). const preamble = buildDispatchPreamble({ taskId: task.id, dispatchId: dispatch.id, - canDispatchSubWorkers: dispatch.depth < params.nestedWorkerMaxDepth, // Why (§3.4): strippedSpec drops the allow-stale-base line so the worker doesn't read the infra flag as an instruction. taskSpec: strippedSpec, coordinatorHandle: params.coordinatorHandle, @@ -138,17 +149,6 @@ export async function dispatchTaskToWorker(params: { try { await runtime.sendTerminalAgentPrompt(targetHandle, preamble + gateContext) } catch (err) { - // Why (#16095): Enter is written before submission is verified, so a stall is only ever an - // unobserved turn start — never proof the preamble is missing. Failing here would reset the - // task to 'ready' and paste the whole preamble a second time into a worker already running it, - // and would revoke the capability its worker_done needs. - if (isAgentPromptStalledError(err)) { - onLog( - `Dispatched task ${task.id} to ${targetHandle}; turn start was not observed. ` + - `The preamble is already in the pane, so the dispatch stays active instead of being resent.` - ) - return 'dispatched-unobserved' - } const updated = db.failDispatch(dispatch.id, err instanceof Error ? err.message : String(err)) if (updated?.status === 'circuit_broken') { params.onCircuitBroken(task.id) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 252c9f89ea9..9478785bcdf 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -1,7 +1,12 @@ import type { OrchestrationDb } from './db' -import type { MessageRow, CoordinatorStatus } from './types' +import type { MessageRow, TaskRow, CoordinatorStatus } from './types' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' -import type { CoordinatorRuntime, WorktreeDrift } from './coordinator-runtime-contract' +import type { + CoordinatorRuntime, + DispatchAgentIdentity, + WorktreeDrift +} from './coordinator-runtime-contract' +export type { CoordinatorRuntime, DispatchAgentIdentity, DispatchAgentLaunchValidation } from './coordinator-runtime-contract' import { applyEscalationToDispatch } from './coordinator-escalation-triage' import { evaluateDagConvergence } from './coordinator-dag-convergence' import { @@ -22,6 +27,7 @@ export type CoordinatorOptions = { maxConcurrent?: number worktree?: string onLog?: (msg: string) => void + resolveDispatchIdentity?: (task: TaskRow, targetHandle: string) => DispatchAgentIdentity | null } type CoordinatorState = { @@ -40,9 +46,12 @@ export class Coordinator { private runtime: CoordinatorRuntime private state: CoordinatorState private stopped = false - private opts: Required> & { + private opts: Required< + Omit + > & { onLog: (msg: string) => void worktree?: string + resolveDispatchIdentity?: (task: TaskRow, targetHandle: string) => DispatchAgentIdentity | null } constructor(db: OrchestrationDb, runtime: CoordinatorRuntime, options: CoordinatorOptions) { @@ -54,7 +63,8 @@ export class Coordinator { pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_MS, maxConcurrent: options.maxConcurrent ?? MAX_CONCURRENT_DEFAULT, worktree: options.worktree, - onLog: options.onLog ?? (() => {}) + onLog: options.onLog ?? (() => {}), + resolveDispatchIdentity: options.resolveDispatchIdentity } this.state = { runId: '', @@ -287,7 +297,8 @@ export class Coordinator { nestedWorkerMaxDepth: this.runtime.getNestedWorkerMaxDepth?.() ?? NESTED_WORKER_MAX_DEPTH_DEFAULT, onLog: this.opts.onLog, - onCircuitBroken: (taskId) => this.state.failedTasks.push(taskId) + onCircuitBroken: (taskId) => this.state.failedTasks.push(taskId), + resolveDispatchIdentity: this.opts.resolveDispatchIdentity }) if (result === 'stale-base-refused') { terminals.unshift(targetHandle) diff --git a/src/main/runtime/orchestration/db-launch-identity.test.ts b/src/main/runtime/orchestration/db-launch-identity.test.ts new file mode 100644 index 00000000000..48fcf6f6218 --- /dev/null +++ b/src/main/runtime/orchestration/db-launch-identity.test.ts @@ -0,0 +1,238 @@ +// U6 orchestration dispatch launch-ownership: identity columns, additive +// structured launch failure alongside the retained generic error, the +// owner-authorized forgotten transition, the tombstone reference accessor, and +// the v5 -> v6 schema migration. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { parsePersistedAgentLaunchFailure } from '../../../shared/agent-launch-failure-schema' +import { retryRecoveryGateForFailureCode } from '../../agent-launch/agent-launch-reconciliation' +import { OrchestrationDb } from './db' + +// The only keys a persisted launch failure may carry; anything outside this set +// (an env key/value, argv element, label, or path) would be a leak. +const ALLOWED_FAILURE_KEYS = new Set([ + 'code', + 'requestedAgent', + 'baseAgent', + 'variable', + 'field', + 'shell', + 'reason', + 'version', + 'failureId', + 'intent', + 'occurredAt' +]) + +const FAILURE = { + code: 'invalid_launch_snapshot' as const, + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as const, + baseAgent: 'codex' as const, + version: 1 as const, + failureId: 'orch-fail-1', + intent: 'orchestration' as const, + occurredAt: 100 +} + +describe('OrchestrationDb U6 launch identity, structured failure, and forget', () => { + let db: OrchestrationDb | undefined + + afterEach(() => { + db?.close() + db = undefined + }) + + function createDb(): OrchestrationDb { + db = new OrchestrationDb(':memory:') + return db + } + + it('records the requested/base launch identity on the dispatch row', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a', undefined, { + requestedAgent: FAILURE.requestedAgent, + baseAgent: 'codex' + }) + expect(ctx.requested_agent).toBe(FAILURE.requestedAgent) + expect(ctx.base_agent).toBe('codex') + }) + + it('failDispatch persists the structured launch failure and RETAINS the generic error', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + const after = d.failDispatch(ctx.id, 'Unable to build an agent launch plan.', FAILURE) + expect(after?.last_failure).toBe('Unable to build an agent launch plan.') + expect(JSON.parse(after?.agent_launch_failure ?? 'null')).toMatchObject({ + code: 'invalid_launch_snapshot', + failureId: 'orch-fail-1' + }) + }) + + it('failDispatch without a structured failure leaves a fresh context launch-failure free', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.failDispatch(ctx.id, 'first', FAILURE) + const ctx2 = d.createDispatchContext(task.id, 'term_a') + const after = d.failDispatch(ctx2.id, 'second') + expect(after?.agent_launch_failure).toBeNull() + }) + + it('forgetDispatch settles forgotten + blocks the task, only from dispatched', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + const forgotten = d.forgetDispatch(ctx.id) + expect(forgotten?.status).toBe('forgotten') + expect(d.getTask(task.id)?.status).toBe('blocked') + // A second forget (no longer dispatched) is a no-op. + expect(d.forgetDispatch(ctx.id)).toBeUndefined() + }) + + it('forgetDispatch rejects a failed (not dispatched) context', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.failDispatch(ctx.id, 'boom') + expect(d.forgetDispatch(ctx.id)).toBeUndefined() + }) + + it('markDispatchLaunchUnknown writes the card and keeps the dispatch dispatched', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + const unknown = { ...FAILURE, code: 'launch_state_unknown' as const, failureId: 'orch-unk-1' } + const after = d.markDispatchLaunchUnknown(ctx.id, unknown) + expect(after?.status).toBe('dispatched') + expect(after?.failure_count).toBe(0) + expect(JSON.parse(after?.agent_launch_failure ?? 'null')).toMatchObject({ + code: 'launch_state_unknown', + failureId: 'orch-unk-1' + }) + // The task is untouched — coexistence, not a settle. + expect(d.getTask(task.id)?.status).not.toBe('failed') + }) + + it('clearDispatchLaunchFailure drops the card while keeping the dispatch dispatched', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.markDispatchLaunchUnknown(ctx.id, { ...FAILURE, code: 'launch_state_unknown' as const }) + const after = d.clearDispatchLaunchFailure(ctx.id) + expect(after?.status).toBe('dispatched') + expect(after?.agent_launch_failure).toBeNull() + }) + + it('SQLite round trip keeps the failure secret-free and re-normalizable (G6)', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + const after = d.failDispatch(ctx.id, 'boom', { ...FAILURE, field: 'env', shell: 'posix' }) + const stored = JSON.parse(after?.agent_launch_failure ?? 'null') + // The stored JSON carries only whitelisted keys — no argv/env/command/path text. + for (const key of Object.keys(stored)) { + expect(ALLOWED_FAILURE_KEYS.has(key)).toBe(true) + } + // It normalizes back through the strict schema on read. + expect(parsePersistedAgentLaunchFailure(stored)).not.toBeNull() + // A tampered stored blob with secret text fails normalization, and a request + // error can never parse as the persisted orchestration failure. + expect(parsePersistedAgentLaunchFailure({ ...stored, agentEnv: { TOKEN: 'x' } })).toBeNull() + expect( + parsePersistedAgentLaunchFailure({ + code: 'idempotency_conflict', + version: 1, + failureId: 'x', + intent: 'orchestration', + occurredAt: 1 + }) + ).toBeNull() + }) + + it('reopening from disk keeps an unknown-launch dispatch non-retryable and secret-free (G6)', () => { + const dir = mkdtempSync(join(tmpdir(), 'orch-reload-')) + const file = join(dir, 'orch.db') + try { + const first = new OrchestrationDb(file) + const task = first.createTask({ spec: 'work' }) + const ctx = first.createDispatchContext(task.id, 'term_a') + const unknown = { ...FAILURE, code: 'launch_state_unknown' as const, failureId: 'orch-unk-1' } + first.markDispatchLaunchUnknown(ctx.id, unknown) + first.close() + + // Rehydrate from the same file — a fresh process reading the persisted row. + const reopened = new OrchestrationDb(file) + db = reopened + const row = reopened.getDispatchContextById(ctx.id) + // Coexistence survives the reload: still dispatched with the unknown card, + // so the coordinator sees an active dispatch and does not re-dispatch. + expect(row?.status).toBe('dispatched') + expect(reopened.getTask(task.id)?.status).not.toBe('failed') + + const stored = JSON.parse(row?.agent_launch_failure ?? 'null') + // On-disk round trip carries only whitelisted keys — no argv/env/command/path. + for (const key of Object.keys(stored)) { + expect(ALLOWED_FAILURE_KEYS.has(key)).toBe(true) + } + // The reloaded failure re-normalizes and stays non-retryable. + expect(parsePersistedAgentLaunchFailure(stored)).not.toBeNull() + expect(retryRecoveryGateForFailureCode(stored.code).kind).toBe('launch_state_unknown') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('referencedRequestedAgents lists custom ids for the tombstone owner', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + d.createDispatchContext(task.id, 'term_a', undefined, { + requestedAgent: FAILURE.requestedAgent, + baseAgent: 'codex' + }) + const task2 = d.createTask({ spec: 'plain' }) + d.createDispatchContext(task2.id, 'term_b') + expect(d.referencedRequestedAgents()).toEqual([FAILURE.requestedAgent]) + }) + + it('migrates a v5 db, preserving rows, backfilling null columns, and allowing forgotten', () => { + const dir = mkdtempSync(join(tmpdir(), 'orch-v5-')) + const file = join(dir, 'orch.db') + try { + const raw = new Database(file) + raw.exec(` + CREATE TABLE dispatch_contexts ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, assignee_handle TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','dispatched','completed','failed','circuit_broken')), + failure_count INTEGER NOT NULL DEFAULT 0, last_failure TEXT, + dispatched_at TEXT, completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), last_heartbeat_at TEXT + ); + INSERT INTO dispatch_contexts (id, task_id, assignee_handle, status, failure_count, last_failure) + VALUES ('ctx_old', 'task_old', 'term_old', 'failed', 2, 'boom'); + `) + raw.pragma('user_version = 5') + raw.close() + + const d = new OrchestrationDb(file) + db = d + const row = d.getDispatchContextById('ctx_old') + expect(row?.last_failure).toBe('boom') + expect(row?.failure_count).toBe(2) + expect(row?.requested_agent).toBeNull() + expect(row?.agent_launch_failure).toBeNull() + // The widened CHECK now accepts 'forgotten': dispatch a fresh task on the + // migrated schema and forget it. + const task = d.createTask({ spec: 'post-migration work' }) + const ctx = d.createDispatchContext(task.id, 'term_new') + expect(d.forgetDispatch(ctx.id)?.status).toBe('forgotten') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts index 69f12d9bbd6..ce6ca623b14 100644 --- a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts @@ -4,7 +4,7 @@ import { attachDispatchCapability } from './dispatch-context/dispatch-capability import { attachDispatchCompletion } from './dispatch-context/dispatch-completion' import { attachDispatchContextStore } from './dispatch-context/dispatch-context-store' import { attachDispatchLookup } from './dispatch-context/dispatch-lookup' -import { attachDispatchDepth } from './dispatch-depth' +import { attachDispatchLaunchIdentity } from './dispatch-context/dispatch-launch-identity' import { attachWorkerReportSettlement } from './dispatch-context/worker-report-settlement' import { attachFederatedDispatchStore } from './federation/federated-dispatch-store' import { attachFederationRelayAck } from './federation/federation-relay-ack' @@ -116,7 +116,7 @@ export function attachOrchestrationDbMethods(ctor: { prototype: object }): void attachDispatchContextStore(ctor) attachDispatchCapability(ctor) attachDispatchLookup(ctor) - attachDispatchDepth(ctor) + attachDispatchLaunchIdentity(ctor) attachDispatchCompletion(ctor) attachWorkerReportSettlement(ctor) attachDecisionGateStore(ctor) diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts index d183516d361..b15bde7de28 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts @@ -1,4 +1,5 @@ import type { TaskStatus, DispatchContextRow } from '../../types' +import type { PersistedAgentLaunchFailure } from '../../../../../shared/agent-launch-contract' import { OrchestrationError } from '../../orchestration-error' import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './dispatch-circuit-breaker' import type { OrchestrationDb } from '../orchestration-db' @@ -77,8 +78,14 @@ export function failDispatch( this: OrchestrationDb, ctxId: string, error: string, - options: { workerProcessExited?: boolean; terminationReason?: string } = {} + options: + | { workerProcessExited?: boolean; terminationReason?: string } + | PersistedAgentLaunchFailure = {} ): DispatchContextRow | undefined { + const workerProcessExited = + 'workerProcessExited' in options && options.workerProcessExited === true + const terminationReason = 'terminationReason' in options ? options.terminationReason : undefined + const launchFailure = 'code' in options ? options : undefined this.db.exec(`SAVEPOINT ${FAIL_DISPATCH_SAVEPOINT}`) try { const result = this.db @@ -99,9 +106,9 @@ export function failDispatch( .run( DISPATCH_CIRCUIT_BREAK_FAILURES, error, - options.terminationReason ?? null, + terminationReason ?? null, ctxId, - options.workerProcessExited ? 1 : 0 + workerProcessExited ? 1 : 0 ) const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as | DispatchContextRow @@ -112,7 +119,7 @@ export function failDispatch( ctx && worker && !['failed', 'succeeded', 'stopped', 'abandoned'].includes(worker.state) && - !options.workerProcessExited + !workerProcessExited ) { throw new OrchestrationError( 'task_not_startable', @@ -123,7 +130,12 @@ export function failDispatch( this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`) return ctx } - if (worker && options.workerProcessExited) { + if (launchFailure) { + this.db + .prepare('UPDATE dispatch_contexts SET agent_launch_failure = ? WHERE id = ?') + .run(JSON.stringify(launchFailure), ctxId) + } + if (worker && workerProcessExited) { this.db .prepare( `UPDATE worker_dispatches diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index cd4083acc0a..28d790163e9 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -3,27 +3,53 @@ import { OrchestrationError } from '../../orchestration-error' import { parsePaneKey } from '../../../../../shared/stable-pane-id' import { CURRENT_CONTRACT_VERSION } from '../contract-constants' import { generateId } from '../generated-id' -import { paneKeyMatchSuffix } from '../pane-key-match' -import { claimDispatchContextRow } from '../dispatch-row-writer' -import type { DispatchCreator } from '../dispatch-depth' +import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( + id, run_id, task_id, contract_version, launch_token_hash, + assignee_handle, assignee_pane_key, process_incarnation, + status, failure_count, dispatched_at, requested_agent, base_agent +) +SELECT ?, run_id, id, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'), ?, ? +FROM tasks +WHERE id = ? AND status = 'ready' + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_handle = ? + AND active.status IN ('pending', 'dispatched') + ) + AND ( + ? IS NULL OR NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_pane_key = ? + AND active.status IN ('pending', 'dispatched') + ) + ) + AND ( + ? IS NULL OR NOT EXISTS ( + SELECT 1 FROM dispatch_contexts active + WHERE active.assignee_pane_key IS NOT NULL + AND active.status IN ('pending', 'dispatched') + AND instr(active.assignee_pane_key, ':') > 1 + AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? + ) + )` + export function createDispatchContext( this: OrchestrationDb, - params: { - taskId: string - assigneeHandle: string - // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. - assigneePaneKey?: string - launchTokenHash?: string - processIncarnation?: string - /** Who is dispatching, for nesting depth. Required so a new caller must decide. */ - creator: DispatchCreator - maxDepth: number - } + taskId: string, + assigneeHandle: string, + // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. + assigneePaneKey?: string, + launchTokenHashOrIdentity?: string | { requestedAgent: string | null; baseAgent: string | null }, + processIncarnation?: string, + identityOverride?: { requestedAgent: string | null; baseAgent: string | null } ): DispatchContextRow { - const { taskId, assigneeHandle, assigneePaneKey, launchTokenHash, processIncarnation } = params - const depth = this.resolveChildDispatchDepth(params.creator, params.maxDepth) + const launchTokenHash = + typeof launchTokenHashOrIdentity === 'string' ? launchTokenHashOrIdentity : undefined + const identity = + typeof launchTokenHashOrIdentity === 'object' ? launchTokenHashOrIdentity : identityOverride const task = this.getTask(taskId) if (!task) { throw new Error(`Task not found: ${taskId}`) @@ -52,18 +78,25 @@ export function createDispatchContext( const id = generateId('ctx') this.db.exec('SAVEPOINT create_dispatch_context') try { - const inserted = claimDispatchContextRow(this.db, { - id, - contractVersion: CURRENT_CONTRACT_VERSION, - launchTokenHash: launchTokenHash ?? null, - assigneeHandle, - assigneePaneKey: assigneePaneKey ?? null, - processIncarnation: processIncarnation ?? null, - priorFailures, - depth, - taskId, - paneSuffix - }) + const inserted = this.db + .prepare(DISPATCH_CONTEXT_CLAIM_SQL) + .run( + id, + CURRENT_CONTRACT_VERSION, + launchTokenHash ?? null, + assigneeHandle, + assigneePaneKey ?? null, + processIncarnation ?? null, + priorFailures, + identity?.requestedAgent ?? null, + identity?.baseAgent ?? null, + taskId, + assigneeHandle, + assigneePaneKey ?? null, + assigneePaneKey ?? null, + paneSuffix, + paneSuffix + ) if (inserted.changes !== 1) { const current = this.getTask(taskId) const occupied = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey) diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-launch-identity.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-launch-identity.ts new file mode 100644 index 00000000000..a962b0d162f --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-launch-identity.ts @@ -0,0 +1,62 @@ +import type { PersistedAgentLaunchFailure } from '../../../../../shared/agent-launch-contract' +import type { DispatchContextRow } from '../../types' +import type { OrchestrationDb } from '../orchestration-db' + +export type DispatchLaunchIdentity = { + requestedAgent: string | null + baseAgent: string | null +} + +export function forgetDispatch(this: OrchestrationDb, ctxId: string): DispatchContextRow | undefined { + const ctx = this.getDispatchContextById(ctxId) + if (!ctx || ctx.status !== 'dispatched') { + return undefined + } + this.db.prepare("UPDATE dispatch_contexts SET status = 'forgotten' WHERE id = ?").run(ctxId) + this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(ctx.task_id) + return this.getDispatchContextById(ctxId) +} + +export function clearDispatchLaunchFailure( + this: OrchestrationDb, + ctxId: string +): DispatchContextRow | undefined { + this.db + .prepare('UPDATE dispatch_contexts SET agent_launch_failure = NULL WHERE id = ?') + .run(ctxId) + return this.getDispatchContextById(ctxId) +} + +export function markDispatchLaunchUnknown( + this: OrchestrationDb, + ctxId: string, + failure: PersistedAgentLaunchFailure +): DispatchContextRow | undefined { + this.db + .prepare('UPDATE dispatch_contexts SET agent_launch_failure = ? WHERE id = ?') + .run(JSON.stringify(failure), ctxId) + return this.getDispatchContextById(ctxId) +} + +export function referencedRequestedAgents(this: OrchestrationDb): string[] { + const rows = this.db + .prepare('SELECT requested_agent FROM dispatch_contexts WHERE requested_agent IS NOT NULL') + .all() as { requested_agent: string }[] + return rows.map((row) => row.requested_agent) +} + +export type DispatchLaunchIdentityMethods = { + forgetDispatch: typeof forgetDispatch + clearDispatchLaunchFailure: typeof clearDispatchLaunchFailure + markDispatchLaunchUnknown: typeof markDispatchLaunchUnknown + referencedRequestedAgents: typeof referencedRequestedAgents +} + +export function attachDispatchLaunchIdentity(ctor: { prototype: object }): void { + Object.assign(ctor.prototype, { + forgetDispatch, + clearDispatchLaunchFailure, + markDispatchLaunchUnknown, + referencedRequestedAgents + }) +} diff --git a/src/main/runtime/orchestration/db/orchestration-db-methods.ts b/src/main/runtime/orchestration/db/orchestration-db-methods.ts index 7f25b209c54..e0277df5c17 100644 --- a/src/main/runtime/orchestration/db/orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/orchestration-db-methods.ts @@ -4,7 +4,7 @@ import type { DispatchCapabilityMethods } from './dispatch-context/dispatch-capa import type { DispatchCompletionMethods } from './dispatch-context/dispatch-completion' import type { DispatchContextStoreMethods } from './dispatch-context/dispatch-context-store' import type { DispatchLookupMethods } from './dispatch-context/dispatch-lookup' -import type { DispatchDepthMethods } from './dispatch-depth' +import type { DispatchLaunchIdentityMethods } from './dispatch-context/dispatch-launch-identity' import type { WorkerReportSettlementMethods } from './dispatch-context/worker-report-settlement' import type { FederatedDispatchStoreMethods } from './federation/federated-dispatch-store' import type { FederationRelayAckMethods } from './federation/federation-relay-ack' @@ -115,7 +115,7 @@ export type OrchestrationDbMethods = CreateTablesMethods & DispatchContextStoreMethods & DispatchCapabilityMethods & DispatchLookupMethods & - DispatchDepthMethods & + DispatchLaunchIdentityMethods & DispatchCompletionMethods & WorkerReportSettlementMethods & DecisionGateStoreMethods & diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index 07a47c3c80c..859e5ca6e2e 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( process_incarnation TEXT, capability_revoked_at TEXT, status TEXT NOT NULL DEFAULT 'pending' - CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), + CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken', 'forgotten')), failure_count INTEGER NOT NULL DEFAULT 0, last_failure TEXT, -- Why the process is gone, when Orca could establish it. See TerminalExitCause. @@ -140,7 +140,10 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( dispatched_at TEXT, completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_heartbeat_at TEXT + last_heartbeat_at TEXT, + requested_agent TEXT, + base_agent TEXT, + agent_launch_failure TEXT ); CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id); diff --git a/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts b/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts index 654395bcd2c..221c6a161c4 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts @@ -1,11 +1,8 @@ import { migrateMutationReceiptCapacity } from '../../mutation-receipt-capacity' -import { - DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, - REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL -} from '../pane-key-match' +import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' -export function applySchemaMigrationsV13ToV30(this: OrchestrationDb, current: number): void { +export function applySchemaMigrationsV13ToV29(this: OrchestrationDb, current: number): void { if (current < 13 && !this.hasColumn('worker_dispatches', 'runtime_epoch')) { this.db.exec('ALTER TABLE worker_dispatches ADD COLUMN runtime_epoch TEXT') } @@ -160,28 +157,16 @@ export function applySchemaMigrationsV13ToV30(this: OrchestrationDb, current: nu if (current < 29 && !this.hasColumn('dispatch_contexts', 'termination_reason')) { this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN termination_reason TEXT') } - if (current < 30) { - if (!this.hasColumn('dispatch_contexts', 'depth')) { - this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN depth INTEGER NOT NULL DEFAULT 1') + if (current < 29) { + if (!this.hasColumn('dispatch_contexts', 'requested_agent')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN requested_agent TEXT') } - if (!this.hasColumn('remote_dispatch_attachments', 'depth')) { - this.db.exec( - 'ALTER TABLE remote_dispatch_attachments ADD COLUMN depth INTEGER NOT NULL DEFAULT 1' - ) + if (!this.hasColumn('dispatch_contexts', 'base_agent')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN base_agent TEXT') + } + if (!this.hasColumn('dispatch_contexts', 'agent_launch_failure')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN agent_launch_failure TEXT') } - // Why drop first: CREATE INDEX IF NOT EXISTS cannot widen an existing - // partial index predicate, and these two covered only starting/ready. - this.db.exec(` - DROP INDEX IF EXISTS idx_remote_dispatch_attachments_active_pane; - DROP INDEX IF EXISTS idx_remote_dispatch_attachments_active_pane_suffix; - CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane - ON remote_dispatch_attachments(pane_key) - WHERE state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown'); - CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane_suffix - ON remote_dispatch_attachments(${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL}) - WHERE state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown') - AND pane_key IS NOT NULL; - `) } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf diff --git a/src/main/runtime/orchestration/dispatch-launch-validation.test.ts b/src/main/runtime/orchestration/dispatch-launch-validation.test.ts new file mode 100644 index 00000000000..5ed087a26bf --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-launch-validation.test.ts @@ -0,0 +1,122 @@ +// W-T1 (§U9, U6 ledger #1/#16): resolve-only dispatch launch validation. A +// dispatch identity is re-validated against the LIVE catalog with NO safe-fallback +// — a disabled/deleted agent hard-fails the dispatch (the coordinator turns +// ok:false into failDispatch with zero PTY). These pin the failure taxonomy. +import { describe, expect, it } from 'vitest' +import { validateDispatchIdentityAgainstCatalog } from './dispatch-launch-validation' + +const CUSTOM_CODEX_ID = 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd' +const LIVE_CUSTOM = { + id: CUSTOM_CODEX_ID, + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false +} +const DETERMINISTIC = { mintFailureId: () => 'fail-x', now: () => 42 } + +describe('validateDispatchIdentityAgainstCatalog', () => { + it('accepts an enabled built-in agent', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: 'claude', baseAgent: 'claude' }, + {}, + DETERMINISTIC + ) + expect(result).toEqual({ ok: true }) + }) + + it('accepts an enabled live custom agent', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: CUSTOM_CODEX_ID, baseAgent: 'codex' }, + { customTuiAgents: [LIVE_CUSTOM] }, + DETERMINISTIC + ) + expect(result).toEqual({ ok: true }) + }) + + it('hard-fails a disabled built-in as base_agent_disabled', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: 'claude', baseAgent: 'claude' }, + { disabledTuiAgents: ['claude'] }, + DETERMINISTIC + ) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.launchFailure.code).toBe('base_agent_disabled') + }) + + it('hard-fails a custom agent whose base is disabled as base_agent_disabled', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: CUSTOM_CODEX_ID, baseAgent: 'codex' }, + { customTuiAgents: [LIVE_CUSTOM], disabledTuiAgents: ['codex'] }, + DETERMINISTIC + ) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.launchFailure.code).toBe('base_agent_disabled') + }) + + it('hard-fails a disabled custom agent as custom_agent_disabled (no safe-fallback)', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: CUSTOM_CODEX_ID, baseAgent: 'codex' }, + { customTuiAgents: [LIVE_CUSTOM], disabledTuiAgents: [CUSTOM_CODEX_ID] }, + DETERMINISTIC + ) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.launchFailure.code).toBe('custom_agent_disabled') + }) + + it('hard-fails a deleted/unknown requested agent as unknown_agent', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: 'ghost-agent', baseAgent: 'claude' }, + {}, + DETERMINISTIC + ) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.launchFailure.code).toBe('unknown_agent') + }) + + it('stamps the orchestration failure envelope from the injected deps', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: 'ghost-agent', baseAgent: 'claude' }, + {}, + DETERMINISTIC + ) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.launchFailure).toMatchObject({ + code: 'unknown_agent', + version: 1, + failureId: 'fail-x', + intent: 'orchestration', + occurredAt: 42 + }) + expect(result.error).toContain('ghost-agent') + }) + + it('does not throw and reports unknown when settings are absent', () => { + const result = validateDispatchIdentityAgainstCatalog( + { requestedAgent: CUSTOM_CODEX_ID, baseAgent: 'codex' }, + undefined, + DETERMINISTIC + ) + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.launchFailure.code).toBe('unknown_agent') + }) +}) diff --git a/src/main/runtime/orchestration/dispatch-launch-validation.ts b/src/main/runtime/orchestration/dispatch-launch-validation.ts new file mode 100644 index 00000000000..0834be838fc --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-launch-validation.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto' +import type { TuiAgent } from '../../../shared/types' +import { + normalizeAgentCatalog, + type NormalizedAgentCatalogInput +} from '../../../shared/custom-tui-agents' +import type { AgentLaunchFailure } from '../../../shared/agent-launch-contract' +import { + classifyRequestedState, + type RequestedState +} from '../../agent-launch/resolve-agent-selection' +import type { DispatchAgentIdentity, DispatchAgentLaunchValidation } from './coordinator' + +// Why (§U9 W-T1, U6 ledger #1/#16): orchestration dispatch re-validates a +// dispatch identity against the LIVE catalog with NO safe-fallback — unlike an +// interactive stored launch, a disabled/deleted agent hard-fails the dispatch so +// the operator retries the task explicitly. The classifier's failure codes are +// reused verbatim; only the fallback branches are dropped. +function dispatchLaunchFailureForState(state: RequestedState): AgentLaunchFailure | null { + switch (state.state) { + case 'enabled-built-in': + case 'enabled-custom': + return null + case 'base-disabled': + case 'disabled-built-in': + return { code: 'base_agent_disabled', baseAgent: state.base } + case 'disabled-custom': + return { code: 'custom_agent_disabled', requestedAgent: state.agent, baseAgent: state.base } + case 'repair-required': + return { code: 'agent_definition_needs_repair', requestedAgent: state.agent } + case 'missing-with-tombstone': + case 'missing-no-tombstone': + return { code: 'unknown_agent', requestedAgent: state.agent } + } +} + +/** Resolve-only re-validation of a dispatch's agent identity: classifies whether + * the requested agent still resolves to a launchable agent WITHOUT creating a + * terminal or routing through the launch boundary. `deps` inject id/clock so the + * taxonomy is unit-testable; the runtime supplies randomUUID + Date.now. */ +export function validateDispatchIdentityAgainstCatalog( + identity: DispatchAgentIdentity, + settings: NormalizedAgentCatalogInput | undefined, + deps: { mintFailureId: () => string; now: () => number } = { + mintFailureId: () => randomUUID(), + now: () => Date.now() + } +): DispatchAgentLaunchValidation { + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: settings?.customTuiAgents, + deletedCustomTuiAgents: settings?.deletedCustomTuiAgents, + disabledTuiAgents: settings?.disabledTuiAgents, + defaultTuiAgent: settings?.defaultTuiAgent + }) + const failure = dispatchLaunchFailureForState( + classifyRequestedState(identity.requestedAgent as TuiAgent, catalog) + ) + if (!failure) { + return { ok: true } + } + return { + ok: false, + error: `dispatch agent "${identity.requestedAgent}" is not launchable (${failure.code})`, + launchFailure: { + ...failure, + version: 1, + failureId: deps.mintFailureId(), + intent: 'orchestration', + occurredAt: deps.now() + } + } +} diff --git a/src/main/runtime/orchestration/dispatch-status-projection.test.ts b/src/main/runtime/orchestration/dispatch-status-projection.test.ts new file mode 100644 index 00000000000..fe692259a40 --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-status-projection.test.ts @@ -0,0 +1,24 @@ +// U6: the additive `forgotten` dispatch disposition must coalesce to legacy +// `failed` for readers that predate it; every other status passes through +// unchanged. +import { describe, expect, it } from 'vitest' +import { projectDispatchStatusForLegacyReaders, type DispatchStatus } from './types' + +describe('projectDispatchStatusForLegacyReaders', () => { + it('coalesces forgotten to failed so a legacy reader blocks until an explicit retry', () => { + expect(projectDispatchStatusForLegacyReaders('forgotten')).toBe('failed') + }) + + it('passes every non-forgotten status through unchanged', () => { + const passthrough: Exclude[] = [ + 'pending', + 'dispatched', + 'completed', + 'failed', + 'circuit_broken' + ] + for (const status of passthrough) { + expect(projectDispatchStatusForLegacyReaders(status)).toBe(status) + } + }) +}) diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 7fb30f733f0..a7c365fbd08 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -19,9 +19,8 @@ function makeSummary( writable: opts.writable ?? true, lastOutputAt: opts.lastOutputAt ?? null, preview: opts.preview ?? '', - // Why spread and not a default: `agentIdentity` absent is meaningful (unknown), so the - // helper must be able to produce a summary that genuinely lacks the field. - ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {}) + ...(opts.requestedAgent !== undefined ? { requestedAgent: opts.requestedAgent } : {}), + ...(opts.baseAgent !== undefined ? { baseAgent: opts.baseAgent } : {}) } } @@ -100,108 +99,179 @@ describe('resolveGroupAddress', () => { }) describe('agent name groups', () => { - // Why identity and not title: these groups used to match `@` against the terminal - // title, so any pane whose task text mentioned an agent received that agent's messages. - // Routing now reads the identity the host resolved from launch/process evidence it owns. - - it('routes to every pane the host resolved as that agent', () => { + it('matches @claude by validated base attribution, not title', () => { const terminals = [ - makeSummary('term_a', { agentIdentity: 'claude' }), - makeSummary('term_b', { agentIdentity: 'claude' }), - makeSummary('term_c', { agentIdentity: 'codex' }) + makeSummary('term_a', { baseAgent: 'claude' }), + makeSummary('term_b', { baseAgent: 'claude' }), + makeSummary('term_c', { baseAgent: 'codex' }) ] - expect(resolveGroupAddress('@claude', 'term_a', terminals, noStatus)).toEqual(['term_b']) + const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) }) - it.each([ - ['@codex', 'codex'], - ['@openclaude', 'openclaude'], - ['@mimo', 'mimo-code'], - ['@gemini', 'gemini'], - ['@droid', 'droid'], - ['@grok', 'grok'], - ['@cursor', 'cursor'], - ['@opencode', 'opencode'] - ])('routes %s to its agent id', (group, agentIdentity) => { + it('maps the mimo-code base to the @mimo group', () => { const terminals = [ - makeSummary('sender'), - makeSummary('target', { agentIdentity: agentIdentity as never }), - makeSummary('other', { agentIdentity: 'claude' }) + makeSummary('term_a', { baseAgent: 'mimo-code' }), + makeSummary('term_b', { baseAgent: 'mimo-code' }), + makeSummary('term_c', { baseAgent: 'opencode' }) ] - const expected = agentIdentity === 'claude' ? ['target', 'other'] : ['target'] - expect(resolveGroupAddress(group, 'sender', terminals, noStatus)).toEqual(expected) + const result = resolveGroupAddress('@mimo', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('a custom agent joins its base harness group', () => { + // The summary builder resolves a custom requestedAgent to its base; the + // custom terminal is addressable under the base group. + const terminals = [ + makeSummary('term_a', { baseAgent: 'claude' }), + makeSummary('term_b', { + requestedAgent: 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd', + baseAgent: 'claude' + }) + ] + const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('keeps openclaude and claude as distinct groups', () => { + const terminals = [ + makeSummary('term_a', { baseAgent: 'claude' }), + makeSummary('term_b', { baseAgent: 'openclaude' }) + ] + expect(resolveGroupAddress('@claude', 'term_a', terminals, noStatus)).toEqual([]) + expect(resolveGroupAddress('@openclaude', 'term_a', terminals, noStatus)).toEqual(['term_b']) + }) + + it('omits an unattributed terminal rather than guessing from its title', () => { + // A title that reads like an agent name must NOT join the group without + // validated base attribution (U6 coordinator terminals rely on this). + const terminals = [ + makeSummary('term_a', { baseAgent: 'claude' }), + makeSummary('term_b', { title: 'Claude Code' }) + ] + const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + + it('matches @droid by base and excludes the sender', () => { + const terminals = [ + makeSummary('term_a', { baseAgent: 'droid' }), + makeSummary('term_b', { baseAgent: 'droid' }), + makeSummary('term_c', { baseAgent: 'droid' }) + ] + const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b', 'term_c']) + }) + + it('does not map bases without an addressable group', () => { + // 'autohand' has no agent-name group; it is unreachable via base groups. + const terminals = [ + makeSummary('term_a', { baseAgent: 'claude' }), + makeSummary('term_b', { baseAgent: 'autohand' }) + ] + expect(resolveGroupAddress('@claude', 'term_a', terminals, noStatus)).toEqual([]) }) it('is case-insensitive for the group address', () => { - const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })] - expect(resolveGroupAddress('@CLAUDE', 'sender', terminals, noStatus)).toEqual(['target']) - }) - - it('excludes the sender even when the sender is that agent', () => { const terminals = [ - makeSummary('sender', { agentIdentity: 'grok' }), - makeSummary('target', { agentIdentity: 'grok' }) + makeSummary('term_a', { baseAgent: 'codex' }), + makeSummary('term_b', { baseAgent: 'claude' }) ] - expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['target']) + const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) }) - describe('a task title can no longer redirect a message', () => { - // The bug. Recorded titles of this exact shape exist: a Grok pane named - // "Switch Claude and Codex off the load balancer… - grok" received both @claude and @codex. - it('does not route @claude to a Codex pane whose task text names Claude', () => { - const terminals = [ - makeSummary('sender'), - makeSummary('codex_pane', { - agentIdentity: 'codex', - title: 'Review the Claude session-history fix' - }) - ] - expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([]) - }) + it('matches @grok by validated base, excludes sender, and ignores grok-like titles', () => { + const terminals = [ + makeSummary('term_a', { baseAgent: 'grok' }), + makeSummary('term_b', { baseAgent: 'grok' }), + makeSummary('term_c', { baseAgent: 'grok', title: '⠋ Grok' }), + makeSummary('term_d', { title: 'ngrok' }), + makeSummary('term_e', { title: 'GROK CLI' }), + makeSummary('term_g', { baseAgent: 'codex' }) + ] - it('does not route @codex to a Grok pane whose task text names Codex', () => { - const terminals = [ - makeSummary('sender'), - makeSummary('grok_pane', { - agentIdentity: 'grok', - title: 'Switch Claude and Codex off the load balancer… - grok' - }) - ] - expect(resolveGroupAddress('@codex', 'sender', terminals, noStatus)).toEqual([]) - expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['grok_pane']) - }) + const result = resolveGroupAddress('@GrOk', 'term_a', terminals, noStatus) - it('does not route @cursor to a pane merely discussing a text cursor', () => { - const terminals = [ - makeSummary('sender'), - makeSummary('claude_pane', { - agentIdentity: 'claude', - title: 'fix the text cursor blink' - }) - ] - expect(resolveGroupAddress('@cursor', 'sender', terminals, noStatus)).toEqual([]) - }) + expect(result).toEqual(['term_b', 'term_c']) }) - describe('unknown identity fails closed', () => { - // Why: `agentIdentity` is absent when the host predates the field or had no evidence - // beyond the title. Delivery is an action, so unknown must not deliver. The sender sees - // no recipients, which is visible and recoverable; a message in the wrong agent's prompt - // is neither. - it('does not route to a pane with no resolved identity, whatever its title says', () => { - const terminals = [makeSummary('sender'), makeSummary('unknown', { title: 'Claude Code' })] - expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([]) - }) + it('matches @cursor by validated base regardless of title shape', () => { + const terminals = [ + makeSummary('coordinator', { baseAgent: 'claude' }), + makeSummary('term_native', { baseAgent: 'cursor', title: 'Cursor Agent' }), + makeSummary('term_working', { baseAgent: 'cursor', title: '⠋ Cursor Agent' }), + makeSummary('term_renamed', { baseAgent: 'cursor', title: 'reviewer pane' }), + makeSummary('term_title_only', { title: 'Cursor - action required' }) + ] - it('still routes the identity-free groups, which do not depend on the field', () => { - const terminals = [makeSummary('sender'), makeSummary('other', { title: 'Claude Code' })] - expect(resolveGroupAddress('@all', 'sender', terminals, noStatus)).toEqual(['other']) - }) + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_native', 'term_working', 'term_renamed']) }) - it('returns no recipients for an unknown group', () => { - const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })] - expect(resolveGroupAddress('@nonsense', 'sender', terminals, noStatus)).toEqual([]) + it('is case-insensitive for @cursor', () => { + const terminals = [ + makeSummary('coordinator', { baseAgent: 'claude' }), + makeSummary('term_b', { baseAgent: 'cursor' }) + ] + + const result = resolveGroupAddress('@CuRsOr', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_b']) + }) + + // Why: "cursor" is ordinary editor vocabulary in other agents' task-summary + // titles. Base-only resolution must never route @cursor into a live + // non-Cursor agent's prompt no matter what the title says. + it('does not match another agent whose task title mentions a text cursor', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_claude_working', { + title: '⠋ preserve cursor visibility across replays' + }), + makeSummary('term_claude_idle', { title: '✳ Fix the text cursor blink' }), + makeSummary('term_claude_dot', { title: '. fix cursor position' }), + makeSummary('term_claude_star', { title: '* cursor rendering done' }), + makeSummary('term_codex', { title: '⠋ Codex: fix cursor offsets' }), + makeSummary('term_grok', { title: '⠋ - restoring cursor state - grok' }), + makeSummary('term_shell', { title: 'Terminal Cursor and Orca slows down' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + // Why: pin the deliberate tradeoff. Cursor-looking titles without validated + // base attribution resolve to nothing instead of resolving loosely. A silent + // miss (address it by handle) is preferred over delivering into another agent's prompt. + it('does not match a renamed Cursor terminal or the bare process name', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_renamed', { title: 'Cursor - reviewer' }), + makeSummary('term_worker', { title: 'cursor worker 2' }), + makeSummary('term_process', { title: 'cursor-agent' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + it('does not match cursor paths, hyphenated compounds, or dotted tokens', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_rules', { title: '~/cursor-rules' }), + makeSummary('term_path', { title: '/tmp/cursor' }), + makeSummary('term_worker', { title: 'my-cursor-worker' }), + makeSummary('term_file', { title: 'render-cursor-after-bracketed-paste' }), + makeSummary('term_dotted', { title: 'cursor.ts' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) }) }) diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 64c0900bfb9..a4ede3cdd32 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,5 +1,5 @@ import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' -import type { TuiAgent } from '../../../shared/tui-agent' +import type { BuiltInTuiAgent } from '../../../shared/types' // Why: group addresses enable broadcast messaging to logical groups of agents. // Resolution is done at send-time: one message record per recipient, same thread_id, @@ -19,40 +19,25 @@ const AGENT_NAME_GROUPS = [ type AgentNameGroup = (typeof AGENT_NAME_GROUPS)[number] -export function isGroupAddress(to: string): boolean { - return to.startsWith('@') -} - -/** Group name to the agent id the host publishes for a pane. */ -const GROUP_AGENT_IDS: Record = { +// Base-to-group map (oracle 16): agent-name groups resolve from a terminal's +// validated base harness, never its title text. Only bases with an addressable +// group appear; the `mimo-code` base maps to the existing `@mimo` group name. +const BASE_AGENT_TO_GROUP: Partial> = { claude: 'claude', openclaude: 'openclaude', codex: 'codex', opencode: 'opencode', - mimo: 'mimo-code', + 'mimo-code': 'mimo', gemini: 'gemini', droid: 'droid', grok: 'grok', cursor: 'cursor' } -/** - * Whether this terminal IS the addressed agent. - * - * Why the host's resolved identity and not the title: a terminal title is a decoration channel - * that routinely contains other agents' names, because people describe agent work in their task - * titles. Matching `@claude` against the title delivered the message to any pane whose task text - * happened to say "claude" — a Codex pane reviewing a Claude PR received Claude's instructions. - * Recorded titles like "Switch Claude and Codex off the load balancer… - grok" are the ordinary - * case, not a contrived one. - * - * Why an absent identity means NO: `agentIdentity` is absent when the host predates the field or - * had no evidence beyond the title. Delivery is an action, so unknown fails closed. Not - * delivering is visible and recoverable — the sender sees no recipients; delivering to the wrong - * agent is neither. - */ -function terminalIsAgent(terminal: RuntimeTerminalSummary, agentName: AgentNameGroup): boolean { - return terminal.agentIdentity === GROUP_AGENT_IDS[agentName] +export type GroupAddress = '@all' | '@idle' | `@${AgentNameGroup}` | `@worktree:${string}` + +export function isGroupAddress(to: string): boolean { + return to.startsWith('@') } export function resolveGroupAddress( @@ -88,9 +73,9 @@ export function resolveGroupAddress( .map((t) => t.handle) } - // Why: agent-name groups (@claude, @droid, etc.) resolve against the identity the HOST - // published for each pane, so the sender can address every instance of an agent without - // knowing their handles — and without a task title being able to redirect the message. + // Why: agent-name groups (@claude, @droid, etc.) match by the terminal's + // validated base harness, not title text — a custom agent joins its base's + // group, and an unattributed terminal is omitted rather than guessed. const agentName = group.slice(1) // remove @ if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) { return terminals @@ -98,7 +83,7 @@ export function resolveGroupAddress( if (t.handle === senderHandle) { return false } - return terminalIsAgent(t, agentName as AgentNameGroup) + return t.baseAgent !== undefined && BASE_AGENT_TO_GROUP[t.baseAgent] === agentName }) .map((t) => t.handle) } diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index b34f69e3c22..f794720f689 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -19,7 +19,25 @@ export type MessageDeliveryContract = 'legacy_direct' | 'current_delivery' | 'au export type TaskStatus = 'pending' | 'ready' | 'dispatched' | 'completed' | 'failed' | 'blocked' -export type DispatchStatus = 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken' +// `forgotten` (U6) is an additive terminal disposition: an owner forgot a +// dispatch stranded in `launch_state_unknown`. Old readers that predate it must +// see legacy `failed` (projectDispatchStatusForLegacyReaders) because the remote +// process may still exist, so the task blocks until an explicit Retry. +export type DispatchStatus = + | 'pending' + | 'dispatched' + | 'completed' + | 'failed' + | 'circuit_broken' + | 'forgotten' + +/** Project a dispatch status for a reader that lacks the additive `forgotten` + * disposition. Every other status is unchanged. */ +export function projectDispatchStatusForLegacyReaders( + status: DispatchStatus +): Exclude { + return status === 'forgotten' ? 'failed' : status +} export type WorkerReportOutcome = 'succeeded' | 'failed' @@ -286,6 +304,13 @@ export type DispatchContextRow = { completed_at: string | null created_at: string last_heartbeat_at: string | null + // U6 additive columns (all nullable; old rows read null and old readers ignore + // them, keeping `last_failure`). requested/base identity validate launch + // ownership; agent_launch_failure is the JSON-encoded structured launch + // failure alongside the retained generic `last_failure` string. + requested_agent: string | null + base_agent: string | null + agent_launch_failure: string | null } export type DecisionGateRow = { diff --git a/src/main/runtime/remote-runtime-terminal-create-idempotency.ts b/src/main/runtime/remote-runtime-terminal-create-idempotency.ts index b2becfc78d3..82366ef47d3 100644 --- a/src/main/runtime/remote-runtime-terminal-create-idempotency.ts +++ b/src/main/runtime/remote-runtime-terminal-create-idempotency.ts @@ -1,9 +1,15 @@ -import type { RuntimeTerminalCreate } from '../../shared/runtime-types' +import type { + RuntimeTerminalCreate, + RuntimeTerminalCreateAgentLaunchFailure +} from '../../shared/runtime-types' const DEFAULT_MAX_IN_FLIGHT_TERMINAL_CREATES = 4_096 export class RemoteRuntimeTerminalCreateIdempotency { - private readonly inFlight = new Map>() + private readonly inFlight = new Map< + string, + Promise + >() constructor(private readonly maxInFlight = DEFAULT_MAX_IN_FLIGHT_TERMINAL_CREATES) {} @@ -12,7 +18,19 @@ export class RemoteRuntimeTerminalCreateIdempotency { worktreeId: string, clientMutationId: string, create: () => Promise - ): Promise { + ): Promise + run( + clientIdentity: string, + worktreeId: string, + clientMutationId: string, + create: () => Promise + ): Promise + run( + clientIdentity: string, + worktreeId: string, + clientMutationId: string, + create: () => Promise + ): Promise { const key = `${clientIdentity}\0${worktreeId}\0${clientMutationId}` const existing = this.inFlight.get(key) if (existing) { diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index f4b4768865b..e337d9fbea1 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -72,7 +72,11 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'remote_update_manual_required', 'remote_update_not_available', 'remote_update_not_downloaded', - ...AGENT_SESSION_RPC_ERROR_CODES + ...AGENT_SESSION_RPC_ERROR_CODES, + // Why: settings.update rejects legacy whole-owner agent writes with this stable + // code so new clients can detect they must upgrade rather than silently drop + // the write. Standard error envelope, no extra payload. + 'client_upgrade_required' ]) const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) diff --git a/src/main/runtime/rpc/methods/agent-launch-schema.test.ts b/src/main/runtime/rpc/methods/agent-launch-schema.test.ts new file mode 100644 index 00000000000..2141ad2956e --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-schema.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { CreateTerminalTab } from './session-tabs-schemas' +import { AgentLaunchInputSchema, AgentLaunchSpawnRequestSchema } from './agent-launch-spawn-schema' + +const CUSTOM_ID = 'custom-agent:claude:11111111-2222-4333-8444-555555555555' + +describe('legacy launch fields reject custom agent ids', () => { + it('rejects a custom id on the legacy launchAgent field', () => { + const parsed = CreateTerminalTab.safeParse({ + worktree: 'w1', + launchAgent: CUSTOM_ID + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a custom id on the legacy agent preset field', () => { + const parsed = CreateTerminalTab.safeParse({ worktree: 'w1', agent: CUSTOM_ID }) + expect(parsed.success).toBe(false) + }) + + it('accepts a built-in id on the legacy launchAgent field', () => { + const parsed = CreateTerminalTab.safeParse({ worktree: 'w1', launchAgent: 'claude' }) + expect(parsed.success).toBe(true) + }) +}) + +describe('agentLaunch admits custom ids on the sanctioned path', () => { + it('accepts a custom id in selection.agent', () => { + const parsed = AgentLaunchSpawnRequestSchema.safeParse({ + selection: { kind: 'agent', agent: CUSTOM_ID }, + prompt: 'do the thing' + }) + expect(parsed.success).toBe(true) + }) + + it('accepts a default selection with no prompt', () => { + const parsed = AgentLaunchSpawnRequestSchema.safeParse({ + selection: { kind: 'default' }, + allowEmptyPromptLaunch: true + }) + expect(parsed.success).toBe(true) + }) + + it('rejects an unknown agent id', () => { + const parsed = AgentLaunchSpawnRequestSchema.safeParse({ + selection: { kind: 'agent', agent: 'not-a-real-agent' } + }) + expect(parsed.success).toBe(false) + }) + + it('parses through the CreateTerminalTab agentLaunch field', () => { + const parsed = CreateTerminalTab.safeParse({ + worktree: 'w1', + agentLaunch: { selection: { kind: 'agent', agent: CUSTOM_ID }, prompt: 'go' } + }) + expect(parsed.success).toBe(true) + }) +}) + +describe('agentLaunch resume/fork variant', () => { + const validKey = { worktreeId: 'wt-1', baseAgent: 'claude', providerSessionId: 'sess-1' } + + it('accepts a resume by session key', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + resume: { operation: 'resume', sessionKey: validKey } + }) + expect(parsed.success).toBe(true) + }) + + it('accepts a fork by session key', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + resume: { operation: 'fork', sessionKey: validKey } + }) + expect(parsed.success).toBe(true) + }) + + it('still accepts a fresh selection launch on the same input', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + selection: { kind: 'agent', agent: CUSTOM_ID } + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a non-resumable base in the session key', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + resume: { operation: 'resume', sessionKey: { ...validKey, baseAgent: 'cursor' } } + }) + expect(parsed.success).toBe(false) + }) + + it('rejects an unknown operation', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + resume: { operation: 'branch', sessionKey: validKey } + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a provider session id with control characters', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + resume: { operation: 'resume', sessionKey: { ...validKey, providerSessionId: 'badid' } } + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a leading-dash provider session id (argv injection guard)', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + resume: { operation: 'resume', sessionKey: { ...validKey, providerSessionId: '--evil' } } + }) + expect(parsed.success).toBe(false) + }) + + it('parses a resume variant through the CreateTerminalTab agentLaunch field', () => { + const parsed = CreateTerminalTab.safeParse({ + worktree: 'w1', + agentLaunch: { resume: { operation: 'resume', sessionKey: validKey } } + }) + expect(parsed.success).toBe(true) + }) +}) + +describe('agentLaunch AI Vault resume variant', () => { + const validEntry = { executionHostId: 'local', agent: 'codex', sessionId: 'vault-1' } + + it('accepts a resume by vault entry', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + vaultResume: { + operation: 'resume', + entry: { ...validEntry, resumeLocator: 'a'.repeat(64) } + } + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a malformed resume locator', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + vaultResume: { + operation: 'resume', + entry: { ...validEntry, resumeLocator: 'not-a-digest' } + } + }) + expect(parsed.success).toBe(false) + }) + + it('accepts a copy operation and a trusted-desktop filePath', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + vaultResume: { operation: 'copy', entry: { ...validEntry, filePath: '/t/omp.jsonl' } } + }) + expect(parsed.success).toBe(true) + }) + + it('accepts an ssh execution host', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + vaultResume: { operation: 'resume', entry: { ...validEntry, executionHostId: 'ssh:box' } } + }) + expect(parsed.success).toBe(true) + }) + + it('rejects an unparseable execution host', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + vaultResume: { operation: 'resume', entry: { ...validEntry, executionHostId: 'nonsense:x' } } + }) + expect(parsed.success).toBe(false) + }) + + it('rejects an unknown AI Vault agent', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + vaultResume: { operation: 'resume', entry: { ...validEntry, agent: 'not-an-agent' } } + }) + expect(parsed.success).toBe(false) + }) + + it('rejects an unknown operation', () => { + const parsed = AgentLaunchInputSchema.safeParse({ + vaultResume: { operation: 'spawn', entry: validEntry } + }) + expect(parsed.success).toBe(false) + }) + + it('parses a vault resume through the CreateTerminalTab agentLaunch field', () => { + const parsed = CreateTerminalTab.safeParse({ + worktree: 'w1', + agentLaunch: { vaultResume: { operation: 'resume', entry: validEntry } } + }) + expect(parsed.success).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-spawn-schema.ts b/src/main/runtime/rpc/methods/agent-launch-spawn-schema.ts new file mode 100644 index 00000000000..76e1027d36d --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-spawn-schema.ts @@ -0,0 +1,113 @@ +// Zod schema for the nested `agentLaunch` request accepted by terminal-create +// RPC methods (U3). A CUSTOM agent id is admitted only on this sanctioned path +// (`selection.agent` via isTuiAgent); the legacy `launchAgent`/`agent` fields +// stay built-in-only. The host constructs LaunchIntent/AgentReferenceAuthority +// from its authenticated context — never from this payload. + +import { z } from 'zod' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentLaunchInput, + AgentLaunchResumeRequest, + AgentLaunchSpawnRequest, + AgentLaunchVaultResumeRequest +} from '../../../../shared/agent-launch-spawn-request' +import { + hasUnsafeProviderSessionIdChars, + isResumableTuiAgent, + type ResumableTuiAgent +} from '../../../../shared/agent-session-resume' +import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../../shared/ai-vault-types' +import { parseExecutionHostId } from '../../../../shared/execution-host' + +const AgentLaunchSelection = z.union([ + z.object({ + kind: z.literal('agent'), + agent: z.custom(isTuiAgent, { message: 'Unknown agent' }) + }), + z.object({ kind: z.literal('default') }) +]) + +const AgentLaunchSourceRecord = z.object({ + owner: z.enum([ + 'default', + 'quick-command', + 'commit-message', + 'source-control-recipe', + 'session', + 'workspace' + ]), + id: z.string().min(1).max(256).optional() +}) + +export const AgentLaunchSpawnRequestSchema: z.ZodType = z.object({ + selection: AgentLaunchSelection, + prompt: z.string().max(100_000).optional(), + allowEmptyPromptLaunch: z.boolean().optional(), + promptDelivery: z.enum(['submit', 'draft']).optional(), + sourceRecord: AgentLaunchSourceRecord.optional() +}) + +// The ownership key is the ONLY resume/fork input a client supplies; the host +// loads the private record and resolves everything else. `baseAgent` must be a +// resumable base and `providerSessionId` control-char-free and bounded, matching +// the host's own normalization so an untrusted key cannot forge one. +const AgentSessionOwnershipKeySchema = z.object({ + worktreeId: z.string().min(1).max(512), + baseAgent: z.custom(isResumableTuiAgent, { message: 'Unknown base agent' }), + providerSessionId: z + .string() + .min(1) + .max(512) + .refine((value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value), { + message: 'Invalid provider session id' + }) +}) + +export const AgentLaunchResumeRequestSchema: z.ZodType = z.object({ + resume: z.object({ + operation: z.enum(['resume', 'fork']), + sessionKey: AgentSessionOwnershipKeySchema + }) +}) + +// The client echoes the host listing's OWN discovered entry identity; the host +// re-validates it against a fresh scan before use. `executionHostId` must parse +// as a known host kind, and `filePath` is bounded — a runtime/paired RPC omits it +// (the host re-derives it), so it stays optional here. +export const AgentLaunchVaultResumeEntrySchema = z.object({ + executionHostId: z + .string() + .refine((value) => parseExecutionHostId(value) !== null, { message: 'Invalid execution host' }) + .transform( + (value) => value as AgentLaunchVaultResumeRequest['vaultResume']['entry']['executionHostId'] + ), + agent: z.custom( + (value) => typeof value === 'string' && (AI_VAULT_AGENTS as readonly string[]).includes(value), + { message: 'Unknown AI Vault agent' } + ), + sessionId: z.string().min(1).max(512), + resumeLocator: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), + filePath: z.string().min(1).max(4096).optional() +}) + +export const AgentLaunchVaultResumeRequestSchema: z.ZodType = + z.object({ + vaultResume: z.object({ + operation: z.enum(['resume', 'copy']), + entry: AgentLaunchVaultResumeEntrySchema + }) + }) + +/** The agentLaunch input a terminal-create RPC accepts: a fresh selection launch, + * a provider-session resume/fork by ownership key, or an AI Vault session resume. + * Discriminated on `resume` / `vaultResume`. */ +export const AgentLaunchInputSchema: z.ZodType = z.union([ + AgentLaunchResumeRequestSchema, + AgentLaunchVaultResumeRequestSchema, + AgentLaunchSpawnRequestSchema +]) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index d943775ef3b..699316f7c34 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -8,6 +8,7 @@ import { AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' +import { getHostAgentSessionRecordStore } from '../../../agent-launch/agent-session-record-store-host' const { scanAiVaultSessionsInWorker, resolveAiVaultSessionTitlesInWorker } = vi.hoisted(() => ({ scanAiVaultSessionsInWorker: vi.fn(), @@ -419,3 +420,136 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(options.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions') }) }) + +describe('aiVault.resumeCommand handler', () => { + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessionsInWorker.mockReset() + scanAiVaultSessionsInWorker.mockResolvedValue({ + sessions: [makeSession()], + issues: [], + scannedAt: SCANNED_AT + }) + }) + + afterEach(() => { + resetAiVaultSessionListCacheForTests() + }) + + function realRuntimeDispatcher(): RpcDispatcher { + // Why: use the real runtime so the RPC → resolveAiVaultResumeCommand → shared + // scan wiring is exercised end-to-end, not a hand-rolled stub. + const runtime = new OrcaRuntimeService(null, undefined, {}) + return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + } + + it('re-validates the echoed entry and returns the assembled command', async () => { + const response = (await realRuntimeDispatcher().dispatch( + makeRequest('aiVault.resumeCommand', { + entry: { executionHostId: 'local', agent: 'claude', sessionId: 'sess-1' } + }) + )) as { ok: boolean; result: { status: string; command?: string } } + expect(response.ok).toBe(true) + expect(response.result.status).toBe('ok') + expect(response.result.command).toContain('sess-1') + }) + + it('fails closed when the host did not discover the echoed entry', async () => { + const response = (await realRuntimeDispatcher().dispatch( + makeRequest('aiVault.resumeCommand', { + entry: { executionHostId: 'local', agent: 'claude', sessionId: 'ghost' } + }) + )) as { ok: boolean; result: { status: string; failure?: { code: string } } } + expect(response.ok).toBe(true) + expect(response.result).toEqual({ + status: 'failed', + failure: { code: 'invalid_launch_snapshot' } + }) + }) + + it('ignores a client-supplied filePath and re-derives from the discovered entry', async () => { + // The untrusted RPC surface must not let a client point resume at an + // arbitrary path; the host uses its own discovered session identity. + const response = (await realRuntimeDispatcher().dispatch( + makeRequest('aiVault.resumeCommand', { + entry: { + executionHostId: 'local', + agent: 'claude', + sessionId: 'sess-1', + filePath: '/attacker/controlled.jsonl' + } + }) + )) as { ok: boolean; result: { status: string; command?: string } } + expect(response.result.status).toBe('ok') + expect(response.result.command).not.toContain('/attacker/controlled.jsonl') + }) +}) + +describe('aiVault.resumeDetails handler', () => { + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessionsInWorker.mockReset() + scanAiVaultSessionsInWorker.mockResolvedValue({ + sessions: [ + { + ...makeSession(), + id: 'local:codex:sess-1:/tmp/t.jsonl', + agent: 'codex', + resumeCommand: 'codex resume sess-1' + } + ], + issues: [], + scannedAt: SCANNED_AT + }) + getHostAgentSessionRecordStore().rebuildRecordsFrom([ + { + worktreeId: 'wt-source', + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex', + providerSession: { key: 'session_id', id: 'sess-1', transcriptPath: '/tmp/t.jsonl' }, + launchSnapshot: { + version: 1, + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex', + displayLabel: 'Codex Sol', + mode: 'custom', + argv: ['codex', '--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: process.platform, + execution: 'native', + shell: process.platform === 'win32' ? 'powershell' : 'posix', + isRemote: false, + executionHostId: 'local' + } + }, + registeredAt: 1, + updatedAt: 1 + } + ]) + }) + + afterEach(() => { + getHostAgentSessionRecordStore().rebuildRecordsFrom([]) + resetAiVaultSessionListCacheForTests() + }) + + it('returns only the correlated captured argument suffix', async () => { + const runtime = new OrcaRuntimeService(null, undefined, {}) + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + const response = (await dispatcher.dispatch( + makeRequest('aiVault.resumeDetails', { + entry: { executionHostId: 'runtime:env-1', agent: 'codex', sessionId: 'sess-1' } + }) + )) as { ok: boolean; result: { status: string; args?: string[] } } + + expect(response).toMatchObject({ + ok: true, + result: { + status: 'ok', + args: ['--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'] + } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index c689165924d..c88d61d59ba 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -4,14 +4,9 @@ import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title' -import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { - assertLegacyAiVaultResumeAllowed, - projectStructuredAiVaultSessions -} from '../../../ai-vault/structured-session-ownership' +import { AgentLaunchVaultResumeEntrySchema } from './agent-launch-spawn-schema' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. // Each scopePath is a host-local match prefix (validated/capped, never used for @@ -62,7 +57,6 @@ export const AiVaultListSessionsParams = z export const AiVaultPrepareSessionResumeParams = z.object({ agent: z.enum(AI_VAULT_AGENTS), - sessionId: z.string().min(1).max(512).optional(), filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), executionHostId: z.string().optional() @@ -80,6 +74,14 @@ export const AiVaultSessionTitlesParams = z.object({ .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) }) +export const AiVaultResumeCommandParams = z.object({ + entry: AgentLaunchVaultResumeEntrySchema +}) + +export const AiVaultResumeDetailsParams = z.object({ + entry: AgentLaunchVaultResumeEntrySchema +}) + export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.resolveSessionTitles', @@ -90,8 +92,7 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.listSessions', params: AiVaultListSessionsParams, - handler: async (params, { runtime, clientKind, clientCapabilities }) => { - await runtime.ensureStructuredAgentSessionHost() + handler: async (params, { runtime }) => { let result try { result = await runtime.listAiVaultSessions({ @@ -109,32 +110,32 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ } // Why: web clients consume this response directly (no parent-side retag), // so sessions must come back stamped as the runtime host they addressed. - const stamped = params.executionHostId + return params.executionHostId ? restampAiVaultListResult(result, params.executionHostId) : result - return projectStructuredAiVaultSessions( - stamped, - clientKind === undefined || - (clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) - ) } }), defineMethod({ name: 'aiVault.prepareSessionResume', params: AiVaultPrepareSessionResumeParams, - handler: async (params, { runtime }) => { - const args: AiVaultPrepareSessionResumeArgs = { + handler: (params, { runtime }) => + runtime.prepareAiVaultSessionResume({ agent: params.agent, - ...(params.sessionId ? { sessionId: params.sessionId } : {}), filePath: params.filePath, codexHome: params.codexHome, // Why: the RPC executes on the transcript-owning host; never let a // client-provided runtime/SSH stamp escape that host boundary. executionHostId: LOCAL_EXECUTION_HOST_ID - } - await runtime.ensureStructuredAgentSessionHost() - assertLegacyAiVaultResumeAllowed(args) - return runtime.prepareAiVaultSessionResume(args) - } + }) + }), + defineMethod({ + name: 'aiVault.resumeCommand', + params: AiVaultResumeCommandParams, + handler: (params, { runtime }) => runtime.resolveAiVaultResumeCommand(params.entry) + }), + defineMethod({ + name: 'aiVault.resumeDetails', + params: AiVaultResumeDetailsParams, + handler: (params, { runtime }) => runtime.resolveAiVaultResumeDetails(params.entry) }) ] diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index d972bf6a553..0c303cc568c 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -13,16 +13,15 @@ describe('automation RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', listAutomations: vi.fn().mockReturnValue([{ id: 'auto-1', name: 'Daily review' }]), - listAutomationsForScope: vi.fn().mockReturnValue({ - automations: [{ id: 'auto-1', name: 'Daily review' }], - items: [{ automationId: 'auto-1', selector: { kind: 'self' } }] - }), showAutomation: vi.fn().mockReturnValue({ id: 'auto-1', name: 'Daily review' }), createAutomation: vi.fn().mockResolvedValue({ id: 'auto-2', name: 'New review' }), updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', name: 'Paused' }), deleteAutomation: vi.fn().mockReturnValue({ removed: true, id: 'auto-1' }), runAutomationNow: vi.fn().mockResolvedValue({ id: 'run-1', automationId: 'auto-1' }), - listAutomationRuns: vi.fn().mockReturnValue([{ id: 'run-1', automationId: 'auto-1' }]) + listAutomationRuns: vi.fn().mockReturnValue([{ id: 'run-1', automationId: 'auto-1' }]), + forgetAutomationRun: vi + .fn() + .mockReturnValue({ id: 'run-1', automationId: 'auto-1', status: 'dispatch_failed' }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) @@ -73,10 +72,10 @@ describe('automation RPC methods', () => { await dispatcher.dispatch(makeRequest('automation.delete', { id: 'auto-1' })) await dispatcher.dispatch(makeRequest('automation.runNow', { id: 'auto-1' })) await dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1' })) + await dispatcher.dispatch(makeRequest('automation.forgetRun', { runId: 'run-1' })) - expect(runtime.listAutomationsForScope).toHaveBeenCalledWith({}) - // A legacy client sends no precondition, so each call forwards an absent expected owner. - expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1', undefined) + expect(runtime.listAutomations).toHaveBeenCalled() + expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1') expect(runtime.createAutomation).toHaveBeenCalledWith( expect.objectContaining({ name: 'New review', @@ -97,12 +96,12 @@ describe('automation RPC methods', () => { setupDecision: 'run', reuseSession: false, rrule: '0 9 * * 1-5' - }), - { expectedOwner: undefined, destination: undefined } + }) ) - expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1', undefined) - expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1', undefined) - expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1', undefined) + expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1') + expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1') + expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1') + expect(runtime.forgetAutomationRun).toHaveBeenCalledWith('run-1') }) it('rejects unknown providers and invalid schedules', async () => { @@ -153,10 +152,6 @@ describe('automation RPC methods', () => { }) ) - expect(runtime.updateAutomation).toHaveBeenCalledWith( - 'auto-1', - { baseBranch: null }, - { expectedOwner: undefined, destination: undefined } - ) + expect(runtime.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) }) }) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index 3645df3c149..b7fc1d6d2e6 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -1,48 +1,164 @@ -import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { z } from 'zod' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' import { - AutomationCreate, - AutomationId, - AutomationList, - AutomationRuns, - AutomationUpdate -} from './automation-schemas' + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + normalizeAutomationPrecheckTimeoutSeconds +} from '../../../../shared/automation-precheck' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { defineMethod, type RpcMethod } from '../core' +import { + OptionalBoolean, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + requiredNumber, + requiredString +} from '../schemas' -function mutationOwner( - id: string, - expectedOwner: AutomationOwnerPrecondition | undefined, - context: RpcContext -): AutomationOwnerPrecondition | undefined { - if ( - expectedOwner || - context.clientCapabilities === undefined || - context.clientCapabilities.includes(AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY) - ) { - return expectedOwner +const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { + message: 'Unknown provider' +}) + +const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() +const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() +const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) + return z.NEVER } - // Legacy clients cannot echo owner metadata, so snapshot it at the RPC boundary. - return context.runtime.automationOwnerPrecondition(id) ?? undefined -} + return hostId +}) + +const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { + message: 'Invalid automation trigger' +}) + +const AutomationPrecheck = z + .object({ + command: requiredString('Missing precheck command'), + timeoutSeconds: OptionalPositiveInt.transform((value) => + normalizeAutomationPrecheckTimeoutSeconds(value) + ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { + message: 'Precheck timeout is too large' + }) + }) + .nullable() + .optional() + +const OptionalNullablePlainString = z + .unknown() + .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.null(), z.undefined()])) + .optional() + +const TaskProviderIdentity = z + .custom( + (value) => + value !== null && + typeof value === 'object' && + 'provider' in value && + ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) + ) + .optional() + .nullable() + +const TaskSourceContext = z + .object({ + kind: z.literal('task-source'), + provider: z.enum(['github', 'gitlab', 'linear', 'jira']), + projectId: requiredString('Missing source project id'), + hostId: ExecutionHostId, + projectHostSetupId: OptionalNullablePlainString, + repoId: OptionalNullablePlainString, + providerIdentity: TaskProviderIdentity, + accountLabel: OptionalNullablePlainString + }) + .optional() + .nullable() + +const WorkspaceRunContext = z + .object({ + kind: z.literal('workspace-run'), + projectId: requiredString('Missing run project id'), + hostId: ExecutionHostId, + projectHostSetupId: requiredString('Missing project host setup id'), + repoId: requiredString('Missing repo id'), + path: requiredString('Missing run path') + }) + .optional() + .nullable() + +const AutomationId = z.object({ + id: requiredString('Missing automation id') +}) + +const AutomationRuns = z.object({ + automationId: OptionalString +}) + +const AutomationRunId = z.object({ + runId: requiredString('Missing run id') +}) + +const AutomationCreate = z.object({ + name: requiredString('Missing automation name'), + prompt: requiredString('Missing automation prompt'), + precheck: AutomationPrecheck, + agentId: TuiAgent, + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + baseBranch: OptionalPlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule, + dtstart: requiredNumber('Missing trigger start time'), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +const AutomationUpdateFields = z.object({ + name: OptionalString, + prompt: OptionalString, + precheck: AutomationPrecheck, + agentId: TuiAgent.optional(), + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + // Why: update patches distinguish omitted from null so callers can clear a saved base branch. + baseBranch: OptionalNullablePlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule.optional(), + dtstart: requiredNumber('Missing trigger start time').optional(), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +const AutomationUpdate = z.object({ + id: requiredString('Missing automation id'), + updates: AutomationUpdateFields +}) export const AUTOMATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'automation.list', - params: AutomationList, - // The projection retains `automations`, so old clients ignore the added owner metadata. - handler: (params, { runtime }) => runtime.listAutomationsForScope(params) + params: null, + handler: (_params, { runtime }) => ({ automations: runtime.listAutomations() }) }), defineMethod({ name: 'automation.show', params: AutomationId, - // Why: the owner rides along so a client that cannot project one itself — the - // CLI — can echo it back on the mutation that follows. Optional: an older - // host omits it, and an older client ignores it. - handler: (params, { runtime }) => { - const automation = runtime.showAutomation(params.id, params.expectedOwner) - const owner = runtime.automationOwnerPrecondition(params.id) - return owner ? { automation, owner } : { automation } - } + handler: (params, { runtime }) => ({ automation: runtime.showAutomation(params.id) }) }), defineMethod({ name: 'automation.create', @@ -54,37 +170,33 @@ export const AUTOMATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'automation.update', params: AutomationUpdate, - handler: async (params, context) => ({ - automation: await context.runtime.updateAutomation(params.id, params.updates, { - expectedOwner: mutationOwner(params.id, params.expectedOwner, context), - destination: params.destination - }) + handler: async (params, { runtime }) => ({ + automation: await runtime.updateAutomation(params.id, params.updates) }) }), defineMethod({ name: 'automation.delete', params: AutomationId, - handler: (params, context) => - context.runtime.deleteAutomation( - params.id, - mutationOwner(params.id, params.expectedOwner, context) - ) + handler: (params, { runtime }) => runtime.deleteAutomation(params.id) }), defineMethod({ name: 'automation.runNow', params: AutomationId, - handler: async (params, context) => ({ - run: await context.runtime.runAutomationNow( - params.id, - mutationOwner(params.id, params.expectedOwner, context) - ) - }) + handler: async (params, { runtime }) => ({ run: await runtime.runAutomationNow(params.id) }) }), defineMethod({ name: 'automation.runs', params: AutomationRuns, handler: (params, { runtime }) => ({ - runs: runtime.listAutomationRuns(params.automationId, params.expectedOwner) + runs: runtime.listAutomationRuns(params.automationId) }) + }), + defineMethod({ + // Why (§U9 W-T3, SSH use case): forgetting a run stranded in launch_state_unknown + // must reach the host that owns the runId; the desktop-IPC forgetRun only settles + // local-owned runs, so a remote/SSH-owned run needs this target-routed method. + name: 'automation.forgetRun', + params: AutomationRunId, + handler: (params, { runtime }) => ({ run: runtime.forgetAutomationRun(params.runId) }) }) ] diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 4161064be01..27a2c16ed2e 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -8,18 +8,57 @@ import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas' import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema' +// Why: agent catalog/reference state is owned by the atomic mutation APIs, not +// the generic settings write. A legacy client that includes any of these keys is +// rejected wholesale with client_upgrade_required (no partial apply) so it cannot +// erase a custom reference it is too old to represent. Fields never shipped by an +// old settings.update client (custom/tombstone arrays, revisions, reference +// owners) are absent from the schema above and fail strict() before reaching the +// handler; they are listed here only for defense in depth. +const AGENT_REJECTED_SETTINGS_UPDATE_KEYS = [ + 'defaultTuiAgent', + 'disabledTuiAgents', + 'agentCmdOverrides', + 'agentDefaultArgs', + 'agentDefaultEnv', + 'customTuiAgents', + 'deletedCustomTuiAgents', + 'agentCatalogRevision', + 'agentReferenceRevision', + 'terminalQuickCommands', + 'commitMessageAi', + 'sourceControlAi' +] as const + export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'settings.get', params: null, - handler: (_params, { runtime }) => ({ settings: runtime.getClientSettings() }) + handler: (_params, { runtime }) => ({ + settings: runtime.getClientSettings(), + agentCatalog: runtime.getAgentCatalogSnapshot(), + // Small capability descriptor; the full snapshot ships from + // settings.agentReferences.get so the two never compete under one frame. + agentReferences: { version: 1 as const, revision: runtime.getAgentReferenceRevision() } + }) + }), + defineMethod({ + name: 'settings.agentReferences.get', + params: null, + handler: (_params, { runtime }) => ({ agentReferences: runtime.getAgentReferenceSnapshot() }) }), defineMethod({ name: 'settings.update', params: SettingsUpdate, - handler: async (params, { runtime }) => ({ - settings: await runtime.updateClientSettings(params) - }) + handler: async (params, { runtime }) => { + const provided = params as Record + for (const key of AGENT_REJECTED_SETTINGS_UPDATE_KEYS) { + if (key in provided) { + throw new Error('client_upgrade_required') + } + } + return { settings: await runtime.updateClientSettings(params) } + } }), defineMethod({ name: 'settings.getTerminalQuickCommands', diff --git a/src/main/runtime/rpc/methods/orchestration-gates.ts b/src/main/runtime/rpc/methods/orchestration-gates.ts index 1d9c7622fe9..a2a4d081b44 100644 --- a/src/main/runtime/rpc/methods/orchestration-gates.ts +++ b/src/main/runtime/rpc/methods/orchestration-gates.ts @@ -65,7 +65,12 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ coordinatorHandle, pollIntervalMs: params.pollIntervalMs, maxConcurrent: params.maxConcurrent, - worktree: params.worktree + worktree: params.worktree, + // Why (§U9 W-T1, ledger #9): the dispatch identity is the attribution of + // the terminal that actually receives the work; the resolver reads it + // from the runtime and returns null for an unattributed target (skip). + resolveDispatchIdentity: (_task, targetHandle) => + runtime.resolveDispatchAgentIdentityForHandle(targetHandle) }) activeCoordinator = coordinator diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 1f44e17ea0b..33d37f33de1 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -1,10 +1,12 @@ import { z } from 'zod' import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal-quick-commands' -import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isBuiltInTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/tui-agent' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent' +import { AgentLaunchInputSchema } from './agent-launch-spawn-schema' +import { agentLaunchNoticeCodeSchema } from '../../../../shared/agent-launch-notice-schema' import { OptionalBoolean } from '../schemas' export const WorktreeTabSelector = z.object({ @@ -131,6 +133,12 @@ export const SetTabProps = WorktreeTabSelector.extend({ viewMode: z.enum(['terminal', 'chat']).optional() }) +export const DismissLaunchNotice = WorktreeTabSelector.extend({ + tabId: z.string().min(1), + launchToken: z.string().min(1).max(128), + code: agentLaunchNoticeCodeSchema +}) + export const CreateTerminalTab = WorktreeTabSelector.extend({ afterTabId: z.string().optional(), targetGroupId: z.string().optional(), @@ -142,7 +150,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ launchConfig: sleepingAgentLaunchConfigSchema, launchToken: z.string().min(1).max(128).optional(), agent: z - .custom(isTuiAgent, { + .custom(isBuiltInTuiAgent, { message: 'Unknown agent preset' }) .optional(), @@ -156,10 +164,11 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ // Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan // identity used when preserving resume config across runtime boundaries. launchAgent: z - .custom(isTuiAgent, { + .custom(isBuiltInTuiAgent, { message: 'Unknown launch agent' }) .optional(), + agentLaunch: AgentLaunchInputSchema.optional(), viewMode: z.enum(['terminal', 'chat']).optional(), activate: z.boolean().optional(), select: z.boolean().optional(), diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index be61fc55edf..56973905e7e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -45,7 +45,6 @@ describe('session tab RPC methods', () => { it('defaults legacy paired activation to the authenticated caller identity', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) @@ -171,7 +170,6 @@ describe('session tab RPC methods', () => { it('preserves explicit user closes from current runtime clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn(), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService @@ -199,7 +197,6 @@ describe('session tab RPC methods', () => { it('preserves reasonless explicit closes from authenticated legacy mobile clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn(), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService @@ -222,7 +219,6 @@ describe('session tab RPC methods', () => { it('preserves reasonless closes from authenticated legacy runtime clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn(), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService @@ -246,7 +242,6 @@ describe('session tab RPC methods', () => { it('refuses reasonless closes from runtime clients that negotiated explicit intent', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ closed: true, refused: true, @@ -628,41 +623,33 @@ describe('session tab RPC methods', () => { it('streams all known session tab snapshots and later updates', async () => { const unsubscribe = vi.fn() - const listeners: ((snapshot: unknown, changeSequence: number) => void)[] = [] - const snapshots = [ - { - worktree: 'wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }, - { - worktree: 'wt-2', - publicationEpoch: 'epoch-2', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - ] + const listeners: ((snapshot: unknown) => void)[] = [] const runtime = { getRuntimeId: () => 'test-runtime', - listAllMobileSessionTabs: vi.fn(() => snapshots), - listAllMobileSessionTabsWithChangeSequence: vi.fn(() => ({ - snapshots, - changeSequence: 0 - })), - supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), - onMobileSessionTabsChanged: vi.fn( - (listener: (snapshot: unknown, changeSequence: number) => void) => { - listeners.push(listener) - return unsubscribe + listAllMobileSessionTabs: vi.fn(() => [ + { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + { + worktree: 'wt-2', + publicationEpoch: 'epoch-2', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] } - ), + ]), + onMobileSessionTabsChanged: vi.fn((listener: (snapshot: unknown) => void) => { + listeners.push(listener) + return unsubscribe + }), registerSubscriptionCleanup: vi.fn() } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) @@ -673,18 +660,15 @@ describe('session tab RPC methods', () => { (message) => messages.push(message), { connectionId: 'conn-1' } ) - listeners[0]?.( - { - worktree: 'wt-1', - publicationEpoch: 'epoch-3', - snapshotVersion: 2, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }, - 1 - ) + listeners[0]?.({ + worktree: 'wt-1', + publicationEpoch: 'epoch-3', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith( 'session.tabs:conn-1:*:req-1', @@ -708,11 +692,6 @@ describe('session tab RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', listAllMobileSessionTabs: vi.fn(() => []), - listAllMobileSessionTabsWithChangeSequence: vi.fn(() => ({ - snapshots: [], - changeSequence: 0 - })), - supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn(() => vi.fn()), registerSubscriptionCleanup: vi.fn() } as unknown as OrcaRuntimeService @@ -815,28 +794,169 @@ describe('session tab RPC methods', () => { 'conn-1' ) }) + + it('unsubscribes a session tabs stream using the resolved worktree id and connection id', async () => { + const cleanupSubscription = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + listMobileSessionTabs: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + publicationEpoch: 'test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }), + cleanupSubscription, + cleanupSubscriptionsByPrefix: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.unsubscribe', { worktree: 'id:wt-1' }), + (message) => messages.push(message), + { connectionId: 'conn-1' } + ) + + expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:wt-1') + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: true, + result: { unsubscribed: true } + }) + }) + + it('unsubscribes one shared-control session tab stream by subscription id', async () => { + const cleanupSubscription = vi.fn() + const cleanupSubscriptionsByPrefix = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + listMobileSessionTabs: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + publicationEpoch: 'test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }), + cleanupSubscription, + cleanupSubscriptionsByPrefix + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.unsubscribe', { worktree: 'id:wt-1', subscriptionId: 'sub-1' }), + vi.fn(), + { connectionId: 'conn-1' } + ) + + expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:wt-1:sub-1') + expect(cleanupSubscriptionsByPrefix).not.toHaveBeenCalled() + }) + + it('unsubscribes one shared-control all-session-tabs stream by subscription id', async () => { + const cleanupSubscription = vi.fn() + const cleanupSubscriptionsByPrefix = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + cleanupSubscription, + cleanupSubscriptionsByPrefix + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.unsubscribeAll', { subscriptionId: 'sub-all-1' }), + vi.fn(), + { connectionId: 'conn-1' } + ) + + expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:*:sub-all-1') + expect(cleanupSubscriptionsByPrefix).not.toHaveBeenCalled() + }) }) -function visibleSnapshot() { - return { - worktree: 'wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'tab-1::leaf-1', - activeTabType: 'terminal' as const, - tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }], - tabs: [ - { - type: 'terminal' as const, - id: 'tab-1::leaf-1', - parentTabId: 'tab-1', - leafId: 'leaf-1', - title: 'Terminal', - status: 'ready' as const, - terminal: 'pty-1', - isActive: true - } - ] - } +const MOBILE_TAB_RESULT = { + tab: { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'pty-1', + isActive: true + }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 } + +async function dispatchTabCreateWithClientKind( + dispatcher: RpcDispatcher, + params: unknown, + clientKind?: 'mobile' | 'runtime' +): Promise<{ ok: boolean; result?: unknown }> { + const messages: string[] = [] + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.createTerminal', params), + (m) => messages.push(m), + { clientKind } + ) + return JSON.parse(messages[0]!) +} + +describe('session.tabs.createTerminal host-resolved agentLaunch', () => { + const AGENT_LAUNCH = { selection: { kind: 'agent', agent: 'claude' }, prompt: 'hi' } + + it('forwards agentLaunch + clientKind and returns the created tab', async () => { + const createMobileSessionTerminal = vi.fn().mockResolvedValue(MOBILE_TAB_RESULT) + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatchTabCreateWithClientKind( + dispatcher, + { worktree: 'id:wt-1', command: 'evil --client-controlled', agentLaunch: AGENT_LAUNCH }, + 'mobile' + ) + + expect(response.ok).toBe(true) + expect(createMobileSessionTerminal).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ agentLaunch: AGENT_LAUNCH, clientKind: 'mobile' }) + ) + expect(response.result).toMatchObject({ tab: { id: 'tab-1::leaf-1' } }) + }) + + it('returns the failure arm as an RPC success with no tab created', async () => { + const createMobileSessionTerminal = vi.fn().mockResolvedValue({ + agentLaunch: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + } + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatchTabCreateWithClientKind( + dispatcher, + { worktree: 'id:wt-1', agentLaunch: AGENT_LAUNCH }, + 'runtime' + ) + + expect(response.ok).toBe(true) + expect(response.result).toEqual({ + agentLaunch: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + } + }) + expect(response.result).not.toHaveProperty('tab') + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 3a322e33ed7..560acd8435e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -2,54 +2,60 @@ import { z } from 'zod' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { + ActivateTab, CreateTerminalTab, + DismissLaunchNotice, + MoveTab, + SaveMarkdownTab, SessionTabsUnsubscribe, + SetTabProps, + UpdatePaneLayout, WorktreeTabSelector } from './session-tabs-schemas' import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' -import { - listSessionTabsInventory, - projectSessionTabsForClient, - subscribeSessionTabsInventory -} from './session-tabs-inventory' -import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' -import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' -import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' -import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { - await restoreStructuredTabsIfSupported(runtime, clientCapabilities) - return projectSessionTabsForClient( + handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => + projectSessionTabAgentStatus( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, clientCapabilities ) - } }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, context) => { - await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) - return listSessionTabsInventory(context) - } + handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({ + snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) + }) + }), + defineMethod({ + name: 'session.tabs.activate', + params: ActivateTab, + handler: async (params, { runtime, clientKind, pairedDeviceId }) => + runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId, { + notifyClients: params.notifyClients !== false, + clientNavigationId: pairedDeviceId, + ...(params.intent ? { intent: params.intent } : {}), + navigation: resolveRuntimeNavigationTarget({ + navigation: params.navigation, + notifyClients: params.notifyClients, + clientKind + }) + }) }), - ...SESSION_TAB_MUTATION_METHODS, ...SESSION_TAB_CLOSE_METHODS, defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => { - if (params.command) { - await assertLegacyAiVaultResumeCommandAllowed(params.command, () => - runtime.ensureStructuredAgentSessionHost() - ) - } - return runtime.createMobileSessionTerminal(params.worktree, { + handler: async (params, { runtime, clientKind, signal, pairedDeviceId }) => { + const baseOpts = { afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, command: params.command, @@ -63,6 +69,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), ...(params.viewMode ? { viewMode: params.viewMode } : {}), + // Authenticated RPC scope for host-resolved launches; never client JSON. + clientKind, activate: params.activate, select: params.select, clientNavigationId: pairedDeviceId, @@ -74,9 +82,81 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ // Why: a dead client connection must cancel the surface wait instead // of running down the timeout and rolling back a live tab (#7718). signal + } + // A pre-spawn typed failure returns the failure arm as an RPC success. + if (params.agentLaunch) { + return runtime.createMobileSessionTerminal(params.worktree, { + ...baseOpts, + agentLaunch: params.agentLaunch + }) + } + return runtime.createMobileSessionTerminal(params.worktree, baseOpts) + } + }), + defineMethod({ + name: 'session.tabs.move', + params: MoveTab, + handler: async (params, { runtime }) => { + const base = { + tabId: params.tabId, + targetGroupId: params.targetGroupId + } + if (params.kind === 'reorder') { + return runtime.moveMobileSessionTab(params.worktree, { + ...base, + kind: 'reorder', + tabOrder: params.tabOrder + }) + } + if (params.kind === 'split') { + return runtime.moveMobileSessionTab(params.worktree, { + ...base, + kind: 'split', + splitDirection: params.splitDirection + }) + } + return runtime.moveMobileSessionTab(params.worktree, { + ...base, + kind: 'move-to-group', + index: params.index }) } }), + defineMethod({ + name: 'session.tabs.updatePaneLayout', + params: UpdatePaneLayout, + handler: async (params, { runtime }) => + runtime.updateMobileSessionPaneLayout(params.worktree, { + tabId: params.tabId, + root: params.root, + expandedLeafId: params.expandedLeafId ?? null, + titlesByLeafId: params.titlesByLeafId + }) + }), + defineMethod({ + name: 'session.tabs.setTabProps', + params: SetTabProps, + handler: async (params, { runtime }) => + runtime.setMobileSessionTabProps(params.worktree, { + tabId: params.tabId, + ...(params.color !== undefined ? { color: params.color } : {}), + ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}), + ...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {}) + }) + }), + defineMethod({ + // Authenticated transport gates client access; the token + terminal + enum + // code are the per-terminal authorization. A non-enum code was already + // rejected by the schema, so this fails closed on a foreign/stale token. + name: 'session.tabs.dismissLaunchNotice', + params: DismissLaunchNotice, + handler: async (params, { runtime }) => + runtime.dismissLaunchNotice(params.worktree, { + tabId: params.tabId, + launchToken: params.launchToken, + code: params.code + }) + }), defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, @@ -89,7 +169,6 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ let unsubscribe = (): void => {} let closed = false let initialized = false - await restoreStructuredTabsIfSupported(runtime, clientCapabilities) const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) if (closed) { return @@ -115,7 +194,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ } emit({ type: 'snapshot', - ...projectSessionTabsForClient(initial, clientKind, clientCapabilities) + ...projectSessionTabAgentStatus(initial, clientKind, clientCapabilities) }) initialized = true if (closed) { @@ -126,7 +205,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (snapshot.worktree === subscribedWorktree) { emit({ type: 'updated', - ...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) }) } }, pairedDeviceId) @@ -156,9 +235,61 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, context, emit) => { - await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) - return subscribeSessionTabsInventory(context, emit) + handler: async ( + _params, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + emit + ) => { + let unsubscribe = (): void => {} + let closed = false + // Why: initial listAll errors should return one RPC error, not a leaked + // subscription cleanup that later emits a stray end frame. + let initialized = false + const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` + const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix + // Why: shared-control can carry multiple all-tab subscribers on one + // socket; include the RPC id so closing one does not evict siblings. + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + unsubscribe() + if (initialized) { + emit({ type: 'end' }) + } + }, + connectionId + ) + + if (closed) { + return + } + const snapshots = await Promise.resolve( + runtime.listAllMobileSessionTabs(pairedDeviceId) + ).catch((error) => { + runtime.cleanupSubscription(subscriptionId) + throw error + }) + if (closed) { + return + } + emit({ + type: 'snapshots', + snapshots: snapshots.map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) + }) + initialized = true + + if (closed) { + return + } + unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emit({ + type: 'updated', + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + }) + }, pairedDeviceId) } }), defineMethod({ @@ -179,5 +310,21 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ return { unsubscribed: true } } }), - ...SESSION_TAB_MARKDOWN_METHODS + defineMethod({ + name: 'markdown.readTab', + params: ActivateTab, + handler: async (params, { runtime }) => + runtime.readMobileMarkdownTab(params.worktree, params.tabId) + }), + defineMethod({ + name: 'markdown.saveTab', + params: SaveMarkdownTab, + handler: async (params, { runtime }) => + runtime.saveMobileMarkdownTab( + params.worktree, + params.tabId, + params.baseVersion, + params.content + ) + }) ] diff --git a/src/main/runtime/rpc/methods/terminal.test.ts b/src/main/runtime/rpc/methods/terminal.test.ts new file mode 100644 index 00000000000..0083b02caa6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { TERMINAL_METHODS } from './terminal' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +/** Dispatch a non-streaming method through the streaming path so the + * authenticated clientKind reaches the handler, and return the parsed result. */ +async function dispatchWithClientKind( + dispatcher: RpcDispatcher, + request: RpcRequest, + clientKind?: 'mobile' | 'runtime' +): Promise<{ ok: boolean; result?: unknown }> { + const messages: string[] = [] + await dispatcher.dispatchStreaming(request, (m) => messages.push(m), { clientKind }) + return JSON.parse(messages[0]!) +} + +const AGENT_LAUNCH = { selection: { kind: 'agent', agent: 'claude' }, prompt: 'hi' } + +describe('terminal.create host-resolved agentLaunch', () => { + it('forwards agentLaunch + clientKind and returns the created terminal', async () => { + const createTerminal = vi.fn().mockResolvedValue({ + handle: 'h-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + title: null, + surface: 'background', + agentLaunch: { status: 'launched', receipt: { baseAgent: 'claude' } } + }) + const runtime = { getRuntimeId: () => 'r', createTerminal } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatchWithClientKind( + dispatcher, + makeRequest('terminal.create', { + worktree: 'id:wt-1', + command: 'evil --client-controlled', + agentLaunch: AGENT_LAUNCH + }), + 'mobile' + ) + + expect(response.ok).toBe(true) + expect(createTerminal).toHaveBeenCalledTimes(1) + expect(createTerminal).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ agentLaunch: AGENT_LAUNCH, clientKind: 'mobile' }) + ) + expect(response.result).toMatchObject({ terminal: { handle: 'h-1' } }) + }) + + it('returns the failure arm as an RPC success with no terminal created', async () => { + const createTerminal = vi.fn().mockResolvedValue({ + agentLaunch: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + } + }) + const runtime = { getRuntimeId: () => 'r', createTerminal } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatchWithClientKind( + dispatcher, + makeRequest('terminal.create', { worktree: 'id:wt-1', agentLaunch: AGENT_LAUNCH }), + 'runtime' + ) + + expect(response.ok).toBe(true) + expect(response.result).toEqual({ + agentLaunch: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + } + }) + expect(response.result).not.toHaveProperty('terminal') + }) + + it('leaves the legacy (no-agentLaunch) path forwarding the client command unchanged', async () => { + const createTerminal = vi.fn().mockResolvedValue({ + handle: 'h-2', + worktreeId: 'wt-1', + title: null, + surface: 'background' + }) + const runtime = { getRuntimeId: () => 'r', createTerminal } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatchWithClientKind( + dispatcher, + makeRequest('terminal.create', { worktree: 'id:wt-1', command: 'zsh' }) + ) + + expect(response.ok).toBe(true) + expect(response.result).toMatchObject({ terminal: { handle: 'h-2' } }) + const call = createTerminal.mock.calls[0]![1] + expect(call.command).toBe('zsh') + expect(call).not.toHaveProperty('agentLaunch') + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index d052d3015fb..fb95c19ddb4 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -34,47 +34,55 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ name: 'terminal.create', params: TerminalCreateParams, handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => { - // A focused terminal create predates paired-client navigation. Keep the - // authority boundary here so a remote caller cannot activate the host - // renderer. This legacy RPC remains a background create for paired viewers; - // caller-local selection belongs to the session-tab RPC flow. - const pairedViewer = clientKind !== undefined - const focus = pairedViewer ? false : params.focus === true - const activate = pairedViewer ? false : params.activate === true - const presentation = - pairedViewer && params.presentation === 'focused' ? 'background' : params.presentation - return { - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus, - rendererBacked: params.rendererBacked === true, - activate, - presentation, - tabId: params.tabId, - leafId: params.leafId, + const baseOpts = { + command: params.command, + startupCommandDelivery: params.startupCommandDelivery, + env: params.env, + envToDelete: params.envToDelete, + ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), + ...(params.launchToken ? { launchToken: params.launchToken } : {}), + ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), + clientKind, + title: params.title, + focus: params.focus === true, + rendererBacked: params.rendererBacked === true, + activate: params.activate === true, + presentation: params.presentation, + tabId: params.tabId, + leafId: params.leafId + } + const create = (canonicalWorktreeSelector: string | undefined, preAllocatedHandle?: string) => + params.agentLaunch + ? runtime.createTerminal(canonicalWorktreeSelector, { + ...baseOpts, + agentLaunch: params.agentLaunch, ...(preAllocatedHandle ? { preAllocatedHandle } : {}) }) - ) + : runtime.createTerminal(canonicalWorktreeSelector, { + ...baseOpts, + ...(preAllocatedHandle ? { preAllocatedHandle } : {}) + }) + const result = + params.clientMutationId || params.reconcileExisting === true + ? await runtime.dedupeTerminalCreate( + pairedDeviceId ?? clientId ?? 'local', + params.worktree, + params.clientMutationId, + params.reconcileExisting === true, + create + ) + : await create(params.worktree) + + if (!('handle' in result)) { + return { agentLaunch: result.agentLaunch } } + return { terminal: result } } }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts index f60b418ca05..b00b0bac646 100644 --- a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -1,7 +1,8 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas' import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry' -import { isTuiAgent } from '../../../../../shared/tui-agent-config' +import { isBuiltInTuiAgent } from '../../../../../shared/tui-agent-config' +import { AgentLaunchInputSchema } from '../agent-launch-spawn-schema' export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') @@ -150,7 +151,8 @@ export const TerminalCreateParams = z.object({ }) .optional(), launchToken: OptionalString, - launchAgent: z.string().refine(isTuiAgent).optional(), + launchAgent: z.string().refine(isBuiltInTuiAgent).optional(), + agentLaunch: AgentLaunchInputSchema.optional(), terminalColorQueryReplies: z .object({ foreground: z.string().max(128).optional(), diff --git a/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-methods.ts b/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-methods.ts new file mode 100644 index 00000000000..6873fddf459 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-methods.ts @@ -0,0 +1,102 @@ +// Agent-launch recovery RPC methods for a worktree (U4-U6): retry/forget a +// worktree launch failure, retry/forget a generic background attempt, and the +// redacted capacity-recovery summary. Split out of worktree.ts to keep that file +// under the max-lines limit. Every method scopes admission/idempotency from the +// authenticated clientKind — never from client JSON — and treats the +// expectedFailureId/expectedOperationId fields as anti-race guards, not secrets. + +import { defineMethod, type RpcMethod } from '../core' +import { + WorktreeForgetAgentLaunch, + WorktreeForgetBackgroundAgentLaunch, + WorktreeForgetUnknownAgentLaunchSiblings, + WorktreePendingAgentLaunchSummary, + WorktreeRetryAgentLaunch, + WorktreeRetryBackgroundAgentLaunch, + WorktreeUnknownAgentLaunchSiblingCount +} from './worktree-schemas' + +export const WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'worktree.retryAgentLaunch', + params: WorktreeRetryAgentLaunch, + // Authorization is authenticated worktree access, the same boundary as every + // other worktree mutation. + handler: async (params, { runtime, clientKind }) => + runtime.retryWorktreeAgentLaunch( + params.worktree, + { + expectedFailureId: params.expectedFailureId, + clientMutationId: params.clientMutationId, + action: params.action + }, + clientKind + ) + }), + defineMethod({ + name: 'worktree.forgetAgentLaunch', + params: WorktreeForgetAgentLaunch, + handler: async (params, { runtime, clientKind }) => + runtime.forgetUnknownWorktreeAgentLaunch( + params.worktree, + { + expectedOperationId: params.expectedOperationId, + clientMutationId: params.clientMutationId + }, + clientKind + ) + }), + defineMethod({ + name: 'worktree.retryBackgroundAgentLaunch', + params: WorktreeRetryBackgroundAgentLaunch, + // Authorization is authenticated access to the attempt's worktree. + handler: async (params, { runtime, clientKind }) => + runtime.retryBackgroundAgentLaunch( + { + attemptId: params.attemptId, + expectedFailureId: params.expectedFailureId, + clientMutationId: params.clientMutationId, + action: params.action + }, + clientKind + ) + }), + defineMethod({ + name: 'worktree.forgetBackgroundAgentLaunch', + params: WorktreeForgetBackgroundAgentLaunch, + handler: async (params, { runtime, clientKind }) => + runtime.forgetBackgroundAgentLaunch( + { + attemptId: params.attemptId, + expectedOperationId: params.expectedOperationId, + clientMutationId: params.clientMutationId + }, + clientKind + ) + }), + defineMethod({ + name: 'worktree.pendingAgentLaunchSummary', + params: WorktreePendingAgentLaunchSummary, + // clientKind scopes the admission principal (own rows only). The redacted rows + // are secret-free and carry no token. + handler: async (_params, { runtime, clientKind }) => + runtime.pendingAgentLaunchSummary(clientKind) + }), + defineMethod({ + name: 'worktree.unknownAgentLaunchSiblingCount', + params: WorktreeUnknownAgentLaunchSiblingCount, + // Lazy preflight for the ":498 Also forget N other stranded launches" affordance; + // clientKind scopes the principal, siblings are host-derived, no secrets cross. + handler: async (params, { runtime, clientKind }) => ({ + count: await runtime.unknownWorktreeAgentLaunchSiblingCount(params.worktree, clientKind) + }) + }), + defineMethod({ + name: 'worktree.forgetUnknownAgentLaunchSiblings', + params: WorktreeForgetUnknownAgentLaunchSiblings, + // Same-principal bulk forget on the anchor's disconnected host. Never kills or + // spawns; each sibling settles only its own reservation and self-guards. + handler: async (params, { runtime, clientKind }) => + runtime.forgetUnknownWorktreeAgentLaunchSiblings(params.worktree, clientKind) + }) +] diff --git a/src/main/runtime/rpc/methods/worktree-background-agent-launch.test.ts b/src/main/runtime/rpc/methods/worktree-background-agent-launch.test.ts new file mode 100644 index 00000000000..fe5d0852451 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-background-agent-launch.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +const CANONICAL_UUID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('worktree.retryBackgroundAgentLaunch RPC', () => { + it('rejects a non-canonical clientMutationId before dispatch', async () => { + const retryBackgroundAgentLaunch = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + retryBackgroundAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.retryBackgroundAgentLaunch', { + attemptId: 'attempt-1', + expectedFailureId: 'f1', + clientMutationId: CANONICAL_UUID.toUpperCase(), + action: { kind: 'retry-same' } + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(retryBackgroundAgentLaunch).not.toHaveBeenCalled() + }) + + it('passes a valid request through, keyed by attempt id, and returns its result', async () => { + const receipt = { + requestedAgent: 'claude' as const, + baseAgent: 'claude' as const, + notices: [], + launchToken: 'tok', + catalogRevision: 1 + } + const retryBackgroundAgentLaunch = vi.fn().mockResolvedValue({ status: 'launched', receipt }) + const runtime = { + getRuntimeId: () => 'test-runtime', + retryBackgroundAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.retryBackgroundAgentLaunch', { + attemptId: 'attempt-1', + expectedFailureId: 'f1', + clientMutationId: CANONICAL_UUID, + action: { kind: 'change-agent', agent: 'codex' } + }) + ) + + expect(response).toMatchObject({ ok: true, result: { status: 'launched' } }) + // clientKind is undefined for a local dispatch; it scopes the idempotency + // principal and is never derived from the client JSON. + expect(retryBackgroundAgentLaunch).toHaveBeenCalledWith( + { + attemptId: 'attempt-1', + expectedFailureId: 'f1', + clientMutationId: CANONICAL_UUID, + action: { kind: 'change-agent', agent: 'codex' } + }, + undefined + ) + }) +}) + +describe('worktree.forgetBackgroundAgentLaunch RPC', () => { + it('rejects a non-canonical clientMutationId before dispatch', async () => { + const forgetBackgroundAgentLaunch = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + forgetBackgroundAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.forgetBackgroundAgentLaunch', { + attemptId: 'attempt-1', + expectedOperationId: 'op-1', + clientMutationId: CANONICAL_UUID.toUpperCase() + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(forgetBackgroundAgentLaunch).not.toHaveBeenCalled() + }) + + it('passes a valid forget request through and returns its result', async () => { + const forgetBackgroundAgentLaunch = vi.fn().mockResolvedValue({ status: 'forgotten' }) + const runtime = { + getRuntimeId: () => 'test-runtime', + forgetBackgroundAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.forgetBackgroundAgentLaunch', { + attemptId: 'attempt-1', + expectedOperationId: 'op-1', + clientMutationId: CANONICAL_UUID + }) + ) + + expect(response).toMatchObject({ ok: true, result: { status: 'forgotten' } }) + expect(forgetBackgroundAgentLaunch).toHaveBeenCalledWith( + { + attemptId: 'attempt-1', + expectedOperationId: 'op-1', + clientMutationId: CANONICAL_UUID + }, + undefined + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-create-agent-launch-precreate.test.ts b/src/main/runtime/rpc/methods/worktree-create-agent-launch-precreate.test.ts new file mode 100644 index 00000000000..2990ea1ad4d --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-agent-launch-precreate.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest, RpcContext } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' +import { WorktreeAgentLaunchPreCreateError } from '../../../agent-launch/agent-launch-worktree-resolution' + +function worktreeCreateHandler(): (params: unknown, ctx: RpcContext) => Promise { + const method = WORKTREE_METHODS.find((m) => m.name === 'worktree.create') + if (!method) { + throw new Error('worktree.create method not registered') + } + return method.handler as (params: unknown, ctx: RpcContext) => Promise +} + +const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const, + executionHostId: 'ssh:ssh-target-1' as const +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +const agentLaunch = { selection: { kind: 'default' as const }, allowEmptyPromptLaunch: true } + +const CUSTOM_ID = 'custom-agent:claude:11111111-1111-4111-8111-111111111111' + +describe('worktree.create pre-create agent-launch rejection', () => { + it('returns a pre-create launch failure in-band as created:false, never a thrown RPC error', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockRejectedValue( + new WorktreeAgentLaunchPreCreateError({ + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + }) + ) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { repo: 'repo-1', name: 'agent-launch', agentLaunch }) + ) + + // A pre-create rejection created no worktree, so it is an RPC SUCCESS with + // `created: false` — a thrown error envelope would drop the typed recovery + // hints the composer needs on every transport. + expect(response).toMatchObject({ + ok: true, + result: { + created: false, + agentLaunchResult: { status: 'failed', failure: { code: 'base_agent_disabled' } } + } + }) + const result = (response as { result: Record }).result + expect(result).not.toHaveProperty('worktree') + }) + + it('returns a pre-create request rejection in-band as created:false', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi + .fn() + .mockRejectedValue( + new WorktreeAgentLaunchPreCreateError({ requestError: { code: 'untrusted_reference' } }) + ) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { repo: 'repo-1', name: 'agent-launch', agentLaunch }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { + created: false, + agentLaunchResult: { status: 'rejected', requestError: { code: 'untrusted_reference' } } + } + }) + }) +}) + +describe('worktree.create legacy-path custom-id rejection (U7)', () => { + const REJECTED = { + created: false, + agentLaunchResult: { status: 'rejected', requestError: { code: 'untrusted_reference' } } + } + + it('rejects a remote client naming a custom startupAgent on the legacy path, before any runtime call', async () => { + const showRepo = vi.fn() + const createManagedWorktree = vi.fn() + const runtime = { showRepo, createManagedWorktree } as unknown as RpcContext['runtime'] + + const result = await worktreeCreateHandler()( + { repo: 'repo-1', name: 'wt', startupAgent: CUSTOM_ID }, + { runtime, clientKind: 'mobile' } + ) + + // Rejected at the boundary, in-band as created:false — no worktree, no runtime work. + expect(result).toEqual(REJECTED) + expect(showRepo).not.toHaveBeenCalled() + expect(createManagedWorktree).not.toHaveBeenCalled() + }) + + it('rejects a remote client naming a custom createdWithAgent on the legacy path', async () => { + const showRepo = vi.fn() + const runtime = { showRepo } as unknown as RpcContext['runtime'] + + const result = await worktreeCreateHandler()( + { repo: 'repo-1', name: 'wt', createdWithAgent: CUSTOM_ID }, + { runtime, clientKind: 'runtime' } + ) + + expect(result).toEqual(REJECTED) + expect(showRepo).not.toHaveBeenCalled() + }) + + it('does NOT reject a trusted in-process caller (undefined clientKind) with a custom id — the guard is remote-scoped', async () => { + // Prove the guard was skipped by letting the next runtime call (showRepo) throw a + // sentinel and asserting it propagates — execution proceeded past the guard. + const sentinel = new Error('proceeded-past-guard') + const showRepo = vi.fn().mockRejectedValue(sentinel) + const runtime = { showRepo } as unknown as RpcContext['runtime'] + + await expect( + worktreeCreateHandler()( + { repo: 'repo-1', name: 'wt', startupAgent: CUSTOM_ID }, + { runtime, clientKind: undefined } + ) + ).rejects.toBe(sentinel) + expect(showRepo).toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 932659758bb..24c1540c47a 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -7,7 +7,7 @@ type WorktreeCreateParams = z.infer type ManagedWorktreeCreateArgs = Parameters[0] type CreateProvenance = Pick< ManagedWorktreeCreateArgs, - 'automationProvenance' | 'cliProvenance' | 'creatorProvenance' + 'automationProvenance' | 'cliProvenance' | 'creatorProvenance' | 'agentLaunchClientKind' > /** Wire params → runtime create args. Kept out of the method table so the mapping can grow with @@ -79,6 +79,8 @@ export function buildManagedWorktreeCreateArgs( ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), startupDraft: params.startupDraft, + ...(params.agentLaunch ? { agentLaunch: params.agentLaunch } : {}), + ...(params.agentLaunchTelemetry ? { agentLaunchTelemetry: params.agentLaunchTelemetry } : {}), lineage: { parentWorkspace: params.parentWorkspace, ...(params.parentWorkspaceOrigin ? { parentWorkspaceOrigin: 'manual' as const } : {}), diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts index 61f6eb65e35..ecacbb9f220 100644 --- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -1,6 +1,10 @@ import { z } from 'zod' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { workspaceSourceSchema } from '../../../../shared/telemetry-events' +import { + launchSourceSchema, + requestKindSchema, + workspaceSourceSchema +} from '../../../../shared/telemetry-events' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' @@ -17,6 +21,7 @@ import { CliWorkspaceProvenanceRequest, OptionalTuiAgent } from './worktree-schemas' +import { AgentLaunchSpawnRequestSchema } from './agent-launch-spawn-schema' export const WorktreeCreate = z .object({ @@ -117,6 +122,11 @@ export const WorktreeCreate = z .unknown() .transform((value) => (isTuiAgent(value) ? value : undefined)) .optional(), + // The host owns resolution for this sanctioned launch path. + agentLaunch: AgentLaunchSpawnRequestSchema.optional(), + agentLaunchTelemetry: z + .object({ launch_source: launchSourceSchema, request_kind: requestKindSchema }) + .optional(), // Why: mobile retries a create interrupted by a connection migration with the // same key so the host dedupes instead of spawning a duplicate worktree. clientMutationId: z.string().min(1).max(128).optional(), diff --git a/src/main/runtime/rpc/methods/worktree-forget-agent-launch.test.ts b/src/main/runtime/rpc/methods/worktree-forget-agent-launch.test.ts new file mode 100644 index 00000000000..af4b1fd79f5 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-forget-agent-launch.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +const CANONICAL_UUID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('worktree.forgetAgentLaunch RPC', () => { + it('validates the canonical lowercase UUID clientMutationId before dispatch', async () => { + const forgetUnknownWorktreeAgentLaunch = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + forgetUnknownWorktreeAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.forgetAgentLaunch', { + worktree: 'id:wt-1', + expectedOperationId: 'op-1', + // Uppercase is not canonical lowercase form; rejected before any lookup. + clientMutationId: CANONICAL_UUID.toUpperCase() + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(forgetUnknownWorktreeAgentLaunch).not.toHaveBeenCalled() + }) + + it('passes a valid request through to the runtime and returns its result', async () => { + const forgetUnknownWorktreeAgentLaunch = vi.fn().mockResolvedValue({ status: 'forgotten' }) + const runtime = { + getRuntimeId: () => 'test-runtime', + forgetUnknownWorktreeAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.forgetAgentLaunch', { + worktree: 'id:wt-1', + expectedOperationId: 'op-1', + clientMutationId: CANONICAL_UUID + }) + ) + + expect(response).toMatchObject({ ok: true, result: { status: 'forgotten' } }) + // clientKind is undefined for an in-process/local dispatch; it scopes the + // idempotency principal and is never derived from the client JSON. + expect(forgetUnknownWorktreeAgentLaunch).toHaveBeenCalledWith( + 'id:wt-1', + { expectedOperationId: 'op-1', clientMutationId: CANONICAL_UUID }, + undefined + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-pending-agent-launch-summary.test.ts b/src/main/runtime/rpc/methods/worktree-pending-agent-launch-summary.test.ts new file mode 100644 index 00000000000..75dea42866d --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-pending-agent-launch-summary.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('worktree.pendingAgentLaunchSummary RPC', () => { + it('dispatches with no params and returns the redacted summary', async () => { + const pendingAgentLaunchSummary = vi.fn().mockReturnValue({ + rows: [ + { + sourceKind: 'cli', + baseHarness: 'codex', + targetHostDisplayName: 'x', + admittedAt: 1, + liveness: 'live' + } + ] + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + pendingAgentLaunchSummary + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.pendingAgentLaunchSummary', {}) + ) + + expect(response).toMatchObject({ ok: true, result: { rows: [{ liveness: 'live' }] } }) + // clientKind is undefined for an in-process/local dispatch; it scopes the + // admission principal and is never derived from the client JSON. + expect(pendingAgentLaunchSummary).toHaveBeenCalledWith(undefined) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-retry-agent-launch.test.ts b/src/main/runtime/rpc/methods/worktree-retry-agent-launch.test.ts new file mode 100644 index 00000000000..5a2d6377a1f --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-retry-agent-launch.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +const CANONICAL_UUID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('worktree.retryAgentLaunch RPC', () => { + it('validates the canonical lowercase UUID clientMutationId before dispatch', async () => { + const retryWorktreeAgentLaunch = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + retryWorktreeAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.retryAgentLaunch', { + worktree: 'id:wt-1', + expectedFailureId: 'f1', + // Uppercase is not canonical lowercase form; rejected before any lookup. + clientMutationId: CANONICAL_UUID.toUpperCase(), + action: { kind: 'retry-same' } + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(retryWorktreeAgentLaunch).not.toHaveBeenCalled() + }) + + it('passes a valid request through to the runtime and returns its result', async () => { + const receipt = { + requestedAgent: 'claude' as const, + baseAgent: 'claude' as const, + notices: [], + launchToken: 'tok', + catalogRevision: 1 + } + const retryWorktreeAgentLaunch = vi.fn().mockResolvedValue({ status: 'launched', receipt }) + const runtime = { + getRuntimeId: () => 'test-runtime', + retryWorktreeAgentLaunch + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.retryAgentLaunch', { + worktree: 'id:wt-1', + expectedFailureId: 'f1', + clientMutationId: CANONICAL_UUID, + action: { kind: 'change-agent', agent: 'codex' } + }) + ) + + expect(response).toMatchObject({ ok: true, result: { status: 'launched' } }) + // clientKind is undefined for an in-process/local dispatch; it scopes the + // idempotency principal and is never derived from the client JSON. + expect(retryWorktreeAgentLaunch).toHaveBeenCalledWith( + 'id:wt-1', + { + expectedFailureId: 'f1', + clientMutationId: CANONICAL_UUID, + action: { kind: 'change-agent', agent: 'codex' } + }, + undefined + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index b7c3b9493a9..7d9d65ce789 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -13,6 +13,7 @@ import { TaskSourceContextSchema } from '../../../../shared/task-source-context- import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import { isCanonicalLowercaseUuid } from '../../../agent-launch/agent-launch-operation-store' const OptionalExecutionHostId = z .string() @@ -213,3 +214,45 @@ export const WorktreeResolveMrBase = z.object({ targetBranch: OptionalString, isCrossRepository: OptionalBoolean }) + +export const WorktreeRetryAgentLaunch = WorktreeSelector.extend({ + expectedFailureId: z.string().min(1).max(256), + clientMutationId: z.string().refine(isCanonicalLowercaseUuid, { + message: 'clientMutationId must be a canonical lowercase UUID' + }), + action: z.union([ + z.object({ kind: z.literal('retry-same') }), + z.object({ kind: z.literal('change-agent'), agent: z.custom(isTuiAgent) }) + ]) +}) + +export const WorktreeForgetAgentLaunch = WorktreeSelector.extend({ + expectedOperationId: z.string().min(1).max(256), + clientMutationId: z.string().refine(isCanonicalLowercaseUuid, { + message: 'clientMutationId must be a canonical lowercase UUID' + }) +}) + +export const WorktreeRetryBackgroundAgentLaunch = z.object({ + attemptId: z.string().min(1).max(256), + expectedFailureId: z.string().min(1).max(256), + clientMutationId: z.string().refine(isCanonicalLowercaseUuid, { + message: 'clientMutationId must be a canonical lowercase UUID' + }), + action: z.union([ + z.object({ kind: z.literal('retry-same') }), + z.object({ kind: z.literal('change-agent'), agent: z.custom(isTuiAgent) }) + ]) +}) + +export const WorktreeForgetBackgroundAgentLaunch = z.object({ + attemptId: z.string().min(1).max(256), + expectedOperationId: z.string().min(1).max(256), + clientMutationId: z.string().refine(isCanonicalLowercaseUuid, { + message: 'clientMutationId must be a canonical lowercase UUID' + }) +}) + +export const WorktreePendingAgentLaunchSummary = z.object({}) +export const WorktreeUnknownAgentLaunchSiblingCount = WorktreeSelector +export const WorktreeForgetUnknownAgentLaunchSiblings = WorktreeSelector diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index b3d816496c3..c908290704d 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -4,13 +4,15 @@ import { resolveAutomationWorkspaceProvenance } from '../../../automations/workspace-provenance' import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' -import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance' +import { WorktreeAgentLaunchPreCreateError } from '../../../agent-launch/agent-launch-worktree-resolution' +import { shouldRejectLegacyCustomAgentLaunch } from '../../../agent-launch/legacy-launch-custom-agent-guard' import { defineMethod, type RpcMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { resolvePairedCallerHostId } from './paired-caller-host-id' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-schemas' +import { WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS } from './worktree-agent-launch-recovery-methods' import { WorktreeActivate, WorktreeForceDeleteBranch, @@ -75,12 +77,21 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.create', params: WorktreeCreate, - handler: async (params, context) => - // Why: a mobile create interrupted by a connection migration is retried with - // the same clientMutationId; dedupe so the host returns the in-flight/created - // worktree instead of spawning a duplicate. No key (desktop/CLI) runs plainly. - context.runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + handler: async (params, context) => { + const create = async () => { const { runtime } = context + if ( + shouldRejectLegacyCustomAgentLaunch({ + hasAgentLaunch: params.agentLaunch !== undefined, + requestClientKind: context.clientKind, + requestedAgentId: params.startupAgent ?? params.createdWithAgent + }) + ) { + return { + created: false, + agentLaunchResult: { status: 'rejected', requestError: { code: 'untrusted_reference' } } + } + } const repo = await runtime.showRepo(params.repo) const automationProvenance = resolveAutomationWorkspaceProvenance({ authority: runtime, @@ -96,6 +107,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ params, { automationProvenance, + agentLaunchClientKind: context.clientKind, cliProvenance: buildCliWorkspaceProvenance(params.cliProvenanceRequest, { startupAgent: params.startupAgent ?? params.createdWithAgent, createdAt: Date.now() @@ -113,10 +125,25 @@ export const WORKTREE_METHODS: RpcMethod[] = [ : result } catch (error) { releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) + if (error instanceof WorktreeAgentLaunchPreCreateError && error.failure) { + return { created: false, agentLaunchResult: { status: 'failed', failure: error.failure } } + } + if (error instanceof WorktreeAgentLaunchPreCreateError && error.requestError) { + return { + created: false, + agentLaunchResult: { status: 'rejected', requestError: error.requestError } + } + } throw error } - }) + } + // Older runtime test doubles and mixed-version hosts do not expose dedupe yet. + return context.runtime.dedupeWorktreeCreate + ? context.runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, create) + : create() + } }), + ...WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS, defineMethod({ name: 'worktree.prefetchCreateBase', params: WorktreePrefetchCreateBase, @@ -134,12 +161,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => ({ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { displayName: params.displayName, - ...(params.displayName !== undefined - ? { displayNameIsPinned: displayNameUpdatePinsLabel(params.displayName) } - : {}), linkedIssue: params.linkedIssue, linkedPR: params.linkedPR, - suppressedGitHubPR: params.suppressedGitHubPR, linkedLinearIssue: params.linkedLinearIssue, linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts index 4ecdbc80a8b..a0535e84078 100644 --- a/src/main/runtime/runtime-folder-workspace.ts +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -71,6 +71,13 @@ export function mergeRuntimeFolderWorkspace( ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, - mobileDiffReview: meta.mobileDiffReview + mobileDiffReview: meta.mobileDiffReview, + // Client-safe recovery mirrors only; private launch snapshots stay host-owned. + ...(meta.agentLaunchFailure !== undefined + ? { agentLaunchFailure: meta.agentLaunchFailure } + : {}), + ...(meta.pendingAgentLaunch !== undefined + ? { pendingAgentLaunch: meta.pendingAgentLaunch } + : {}) } } diff --git a/src/main/runtime/terminal-agent-launch-resolution.test.ts b/src/main/runtime/terminal-agent-launch-resolution.test.ts new file mode 100644 index 00000000000..a5e410c3ebc --- /dev/null +++ b/src/main/runtime/terminal-agent-launch-resolution.test.ts @@ -0,0 +1,447 @@ +// The terminal-create host-launch resolver: it drives the shared boundary from a +// terminal workspace descriptor, marks trust through the boundary preflight, +// injects detection, and maps the admitted plan to terminal option fields — never +// argv/env/snapshot beyond the resolved plan. +import { describe, expect, it, vi } from 'vitest' +import { + resolveTerminalAgentLaunch, + type TerminalAgentLaunchDeps +} from './terminal-agent-launch-resolution' +import { AgentSessionRecordStore } from '../agent-launch/agent-session-record-store' +import { AgentLaunchBoundary } from '../agent-launch/agent-launch-boundary' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator +} from '../agent-launch/agent-launch-admission-store' +import type { AgentLaunchHostDescriptor } from '../agent-launch/agent-launch-host-state' +import type { GlobalSettings } from '../../shared/types' +import type { + ResolveAgentLaunchRequest, + ResolvedAgentLaunch, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchOutcome } from '../agent-launch/resolve-agent-launch' +import type { AuthenticatedClientKind } from '../agent-launch/agent-launch-boundary' +import type { TuiAgent } from '../../shared/types' +import { scanForCustomEnvLeak } from '../../shared/custom-env-leak-scan' + +function makeSnapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['/opt/resolved-claude', '--tui'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +function makeLaunch(): ResolvedAgentLaunch { + const snapshot = makeSnapshot() + return { + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + argv: snapshot.argv, + agentEnv: snapshot.agentEnv, + variables: { values: { repoPath: null, worktreePath: null }, referenced: [] }, + snapshot, + policy: { + intent: 'interactive', + mode: 'built-in', + client: 'desktop', + isRemote: false, + platform: 'linux', + promptInjectionMode: 'stdin-after-start', + expectedProcess: 'claude', + env: 'none' + }, + notices: [], + telemetry: { agentKind: 'claude-code', usedCustomAgent: false }, + admissionGuard: { fingerprint: 'fp-1', stableInputDigest: 'sfp-1', basis: 'explicit' } + } +} + +const DESCRIPTOR: AgentLaunchHostDescriptor = { kind: 'local', platform: 'linux', shell: 'posix' } + +function makeDeps( + resolve: (request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome, + overrides: Partial = {} +): TerminalAgentLaunchDeps { + return { + boundary: new AgentLaunchBoundary({ + admissionStore: new AgentLaunchAdmissionStore(), + coordinator: new LaunchAdmissionCoordinator() + }), + getSettings: () => ({}) as GlobalSettings, + getCatalogRevision: () => 4, + detectStockBaseAgents: vi.fn(async () => ['claude']), + resolveTargetHomePath: vi.fn(async () => '/home/dev'), + markWorkspaceTrusted: vi.fn(), + sessionRecordStore: new AgentSessionRecordStore(), + resolve: (request) => resolve(request), + ...overrides + } +} + +function makeArgs(clientKind: AuthenticatedClientKind = undefined) { + return { + request: { selection: { kind: 'agent' as const, agent: 'claude' as const }, prompt: 'go' }, + clientKind, + descriptor: DESCRIPTOR, + scope: 'wt-1', + worktreePath: '/repo/wt', + repoPath: '/repo', + principal: { kind: 'local' as const } + } +} + +describe('resolveTerminalAgentLaunch', () => { + it('maps a resolved launch to terminal fields with the settle token + receipt', async () => { + const resolve = vi.fn(() => ({ ok: true as const, launch: makeLaunch() })) + const trusted: ResolvedAgentLaunch[] = [] + const detectStockBaseAgents = vi.fn(async () => ['claude']) + const deps = makeDeps(resolve, { + detectStockBaseAgents, + markWorkspaceTrusted: (launch) => { + trusted.push(launch) + } + }) + const result = await resolveTerminalAgentLaunch(deps, makeArgs()) + + expect(result.kind).toBe('resolved') + if (result.kind !== 'resolved') { + return + } + // The command is the host-resolved argv, and launchAgent is the built-in base. + expect(result.fields.command).toContain('/opt/resolved-claude') + expect(result.fields.launchAgent).toBe('claude') + expect(typeof result.fields.launchToken).toBe('string') + expect(result.admissionToken).toBe(result.fields.launchToken) + expect(result.receipt.baseAgent).toBe('claude') + expect(result.receipt.catalogRevision).toBe(4) + // A stdin-after-start launch threads the prompt as a post-ready followup the + // host submits on its own spawned terminal (never a client-delivered send). + expect(result.fields.postReadyPrompt).toEqual({ + expectedProcess: 'claude', + followupPrompt: 'go' + }) + // Detection ran against the target descriptor. + expect(detectStockBaseAgents).toHaveBeenCalledWith(DESCRIPTOR) + // Trust preflight marked the workspace for the resolved launch before admission. + expect(trusted).toHaveLength(1) + expect(trusted[0]!.baseAgent).toBe('claude') + }) + + it.each([ + [undefined, 'desktop'], + ['runtime', 'paired-web'], + ['mobile', 'mobile'] + ] as const)('maps clientKind %s to launch intent client %s', async (clientKind, expected) => { + let captured: ResolveAgentLaunchRequest | null = null + const resolve = (request: ResolveAgentLaunchRequest): ResolveAgentLaunchOutcome => { + captured = request + return { ok: true as const, launch: makeLaunch() } + } + const deps = makeDeps(resolve) + await resolveTerminalAgentLaunch(deps, makeArgs(clientKind)) + expect(captured!.intent).toEqual({ kind: 'interactive', client: expected }) + }) + + it('returns a failed outcome (no fields) for a typed resolution failure', async () => { + const resolve = vi.fn(() => ({ + ok: false as const, + failure: { code: 'base_agent_disabled' as const, baseAgent: 'claude' as const } + })) + const deps = makeDeps(resolve) + const result = await resolveTerminalAgentLaunch(deps, makeArgs('mobile')) + expect(result).toEqual({ + kind: 'failed', + outcome: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + } + }) + }) + + it('returns a rejected outcome for a request error', async () => { + const resolve = vi.fn(() => ({ + ok: false as const, + requestError: { code: 'untrusted_reference' as const } + })) + const deps = makeDeps(resolve) + const result = await resolveTerminalAgentLaunch(deps, makeArgs()) + expect(result).toEqual({ + kind: 'failed', + outcome: { status: 'rejected', requestError: { code: 'untrusted_reference' } } + }) + }) + + it('never carries a custom env key/value in the client receipt (G7 oracle-12/13)', async () => { + const ENV_KEY = 'ZZLEAKKEY_RECEIPT' + const ENV_VALUE = 'zzleakvalue_receipt_4b8' + const customId = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd' as TuiAgent + const resolve = vi.fn(() => ({ + ok: true as const, + launch: { + ...makeLaunch(), + requestedAgent: customId, + displayLabel: 'Env Agent', + // The launch object DOES carry the admitted env; the client-crossing + // receipt must drop every trace of it. + agentEnv: { [ENV_KEY]: ENV_VALUE }, + policy: { ...makeLaunch().policy, mode: 'custom' as const, env: 'full' as const }, + notices: [{ code: 'env_withheld' as const, label: 'Env Agent' }], + telemetry: { agentKind: 'claude-code' as const, usedCustomAgent: true } + } + })) + const deps = makeDeps(resolve) + const result = await resolveTerminalAgentLaunch(deps, makeArgs('mobile')) + expect(result.kind).toBe('resolved') + if (result.kind !== 'resolved') { + return + } + expect(scanForCustomEnvLeak(result.receipt, [ENV_KEY, ENV_VALUE])).toEqual([]) + }) + + it('never lets an untrusted client escalate to an unattended intent (GP3)', async () => { + // Ledger #6 GP3 pin: convert the safe-by-construction inference into an + // asserted property. A client cannot mint automation/background/orchestration + // authority on the runtime RPC surface — the host derives the intent from the + // authenticated clientKind, so a client-declared `unattended` is dropped and + // its prompt can only ever ride an interactive (bounded-draft) intent. Owner + // prompt authority is host-constructed and never reachable from a client here. + let captured: ResolveAgentLaunchRequest | null = null + const resolve = (request: ResolveAgentLaunchRequest): ResolveAgentLaunchOutcome => { + captured = request + return { ok: true as const, launch: makeLaunch() } + } + const deps = makeDeps(resolve) + await resolveTerminalAgentLaunch(deps, { + ...makeArgs('mobile'), + request: { + selection: { kind: 'agent' as const, agent: 'claude' as const }, + prompt: 'client-supplied draft that must never ride owner authority', + unattended: { kind: 'background' as const } + } + }) + expect(captured!.intent).toEqual({ kind: 'interactive', client: 'mobile' }) + }) +}) + +describe('resolveTerminalAgentLaunch target-host planning (U7 oracle-14)', () => { + const WINDOWS_POWERSHELL: AgentLaunchHostDescriptor = { + kind: 'local', + platform: 'win32', + shell: 'powershell' + } + const WINDOWS_CMD: AgentLaunchHostDescriptor = { kind: 'local', platform: 'win32', shell: 'cmd' } + const WSL_LINUX: AgentLaunchHostDescriptor = { kind: 'local', platform: 'linux', shell: 'posix' } + const SSH_LINUX: AgentLaunchHostDescriptor = { + kind: 'ssh', + connectionId: 'conn-1', + platform: 'linux', + shell: 'posix' + } + + // A paired-web/iOS client only names the identity; the host plans from the + // TARGET execution host it derives, never the phone/browser OS. The resolver's + // own assembly suite proves platform/shell → target quoting; this proves those + // target values (and the detection descriptor) reach the resolver on the + // untrusted client surface regardless of clientKind. + it.each([ + ['runtime', WINDOWS_POWERSHELL, 'win32', 'powershell'], + ['mobile', WINDOWS_POWERSHELL, 'win32', 'powershell'], + ['runtime', WINDOWS_CMD, 'win32', 'cmd'], + ['mobile', WSL_LINUX, 'linux', 'posix'], + ['runtime', SSH_LINUX, 'linux', 'posix'] + ] as const)( + 'plans a %s client launch from the target descriptor (%o → %s/%s), not the client OS', + async (clientKind, descriptor, platform, shell) => { + let captured: ResolveAgentLaunchRequest | null = null + const resolve = (request: ResolveAgentLaunchRequest): ResolveAgentLaunchOutcome => { + captured = request + return { ok: true as const, launch: makeLaunch() } + } + const detectStockBaseAgents = vi.fn(async () => null) + const deps = makeDeps(resolve, { detectStockBaseAgents }) + await resolveTerminalAgentLaunch(deps, { ...makeArgs(clientKind), descriptor }) + // The target platform/shell reach the resolver — the mobile/web client OS + // never participates in quoting. + expect(captured!.platform).toBe(platform) + expect(captured!.shell).toBe(shell) + expect(captured!.isRemote).toBe(descriptor.kind === 'ssh') + // Stock detection ran against the TARGET descriptor, not a client host. + expect(detectStockBaseAgents).toHaveBeenCalledWith(descriptor) + } + ) +}) + +describe('resolveTerminalAgentLaunch recipe-arg threading (U7)', () => { + it('threads a source-control recipe override from recipeRepo into perLaunchArgs', async () => { + let captured: ResolveAgentLaunchRequest | null = null + const resolve = (request: ResolveAgentLaunchRequest): ResolveAgentLaunchOutcome => { + captured = request + return { ok: true as const, launch: makeLaunch() } + } + const deps = makeDeps(resolve) + await resolveTerminalAgentLaunch(deps, { + ...makeArgs(), + request: { + selection: { kind: 'agent' as const, agent: 'claude' as const }, + prompt: 'go', + sourceRecord: { owner: 'source-control-recipe' as const, id: 'fixChecks' } + }, + // Host-trusted repo override — the caller derives it from the workspace. + recipeRepo: { + sourceControlAi: { actionOverrides: { fixChecks: { agentArgs: '--recipe one' } } } + } + }) + expect(captured!.perLaunchArgs).toBe('--recipe one') + }) + + it('rejects an unknown recipe id with untrusted_reference and never resolves', async () => { + const resolve = vi.fn(() => ({ ok: true as const, launch: makeLaunch() })) + const deps = makeDeps(resolve) + const result = await resolveTerminalAgentLaunch(deps, { + ...makeArgs(), + request: { + selection: { kind: 'agent' as const, agent: 'claude' as const }, + prompt: 'go', + sourceRecord: { owner: 'source-control-recipe' as const, id: 'not-a-real-action' } + }, + recipeRepo: { sourceControlAi: {} } + }) + expect(result).toEqual({ + kind: 'failed', + outcome: { status: 'rejected', requestError: { code: 'untrusted_reference' } } + }) + expect(resolve).not.toHaveBeenCalled() + }) + + it('leaves perLaunchArgs unset when recipeRepo is absent and the record is non-recipe', async () => { + let captured: ResolveAgentLaunchRequest | null = null + const resolve = (request: ResolveAgentLaunchRequest): ResolveAgentLaunchOutcome => { + captured = request + return { ok: true as const, launch: makeLaunch() } + } + const deps = makeDeps(resolve) + await resolveTerminalAgentLaunch(deps, { + ...makeArgs(), + request: { + selection: { kind: 'agent' as const, agent: 'claude' as const }, + prompt: 'go', + sourceRecord: { owner: 'quick-command' as const, id: 'qc-1' } + } + }) + expect('perLaunchArgs' in captured!).toBe(false) + }) +}) + +describe('resolveTerminalAgentLaunch Source Control AI provider contract (plan §1364)', () => { + // §1364: the SAME custom-agent launch assertion must hold for GitHub, GitLab, and + // one non-GitHub/GitLab generic review provider. A provider adapter may supply the + // review work item's task text/URLs, but none may reinterpret the custom agent id + // or assemble its command — the id reaches the resolver unchanged and only the host + // recipe contributes the per-launch argv band. + const CUSTOM_AGENT = 'custom-agent:codex:sc-ai-review' as const + + it.each([ + ['github', 'Review PR github.com/acme/app/pull/12'], + ['gitlab', 'Review MR gitlab.com/acme/app/-/merge_requests/34'], + ['generic', 'Review change bitbucket.org/acme/app/pull-requests/56'] + ] as const)( + 'resolves the same custom-agent recipe launch for a %s review work item', + async (_provider, providerTaskText) => { + let captured: ResolveAgentLaunchRequest | null = null + const resolve = (request: ResolveAgentLaunchRequest): ResolveAgentLaunchOutcome => { + captured = request + return { ok: true as const, launch: makeLaunch() } + } + const deps = makeDeps(resolve) + const result = await resolveTerminalAgentLaunch(deps, { + ...makeArgs(), + request: { + selection: { kind: 'agent' as const, agent: CUSTOM_AGENT }, + // The provider-supplied review context is the only provider-varying input. + prompt: providerTaskText, + sourceRecord: { owner: 'source-control-recipe' as const, id: 'fixChecks' } + }, + recipeRepo: { + sourceControlAi: { actionOverrides: { fixChecks: { agentArgs: '--recipe one' } } } + } + }) + + expect(result.kind).toBe('resolved') + if (result.kind !== 'resolved') { + return + } + // (1) the custom agent id reaches the resolver unchanged — never reinterpreted. + expect(captured!.selection).toEqual({ kind: 'agent', agent: CUSTOM_AGENT }) + // (2) only the host recipe contributes argv; the provider assembles no command. + expect(captured!.perLaunchArgs).toBe('--recipe one') + // (3) the provider-supplied task text flows through as the launch prompt. + expect(result.fields.postReadyPrompt?.followupPrompt).toBe(providerTaskText) + } + ) +}) + +describe('resolveTerminalAgentLaunch resume/fork', () => { + const KEY = { worktreeId: 'wt-1', baseAgent: 'claude' as const, providerSessionId: 'sess-1' } + + function storeWithRecord(): AgentSessionRecordStore { + const store = new AgentSessionRecordStore() + store.register({ + paneKey: 'pane-a', + terminalId: 'term-a', + worktreeId: 'wt-1', + requestedAgent: 'claude', + baseAgent: 'claude', + launchSnapshot: makeSnapshot(), + launchToken: 'token-a' + }) + store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' }) + return store + } + + it('feeds the loaded record snapshot + session into the resolver as a resume intent', async () => { + let captured: ResolveAgentLaunchRequest | null = null + const resolve = (request: ResolveAgentLaunchRequest): ResolveAgentLaunchOutcome => { + captured = request + return { ok: true as const, launch: makeLaunch() } + } + const deps = makeDeps(resolve, { sessionRecordStore: storeWithRecord() }) + const result = await resolveTerminalAgentLaunch(deps, { + ...makeArgs('mobile'), + request: { resume: { operation: 'resume', sessionKey: KEY } } + }) + expect(result.kind).toBe('resolved') + expect(captured!.intent).toEqual({ kind: 'resume', operation: 'resume', client: 'mobile' }) + expect(captured!.persistedSnapshot).toEqual(makeSnapshot()) + expect(captured!.resumeProviderSession).toEqual({ key: 'session_id', id: 'sess-1' }) + }) + + it('fails invalid_launch_snapshot for an unknown session key without resolving', async () => { + const resolve = vi.fn(() => ({ ok: true as const, launch: makeLaunch() })) + const deps = makeDeps(resolve, { sessionRecordStore: new AgentSessionRecordStore() }) + const result = await resolveTerminalAgentLaunch(deps, { + ...makeArgs('mobile'), + request: { resume: { operation: 'resume', sessionKey: KEY } } + }) + expect(result).toEqual({ + kind: 'failed', + outcome: { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + }) + // No record → the resolver was never invoked. + expect(resolve).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/terminal-agent-launch-resolution.ts b/src/main/runtime/terminal-agent-launch-resolution.ts new file mode 100644 index 00000000000..2061b14f37d --- /dev/null +++ b/src/main/runtime/terminal-agent-launch-resolution.ts @@ -0,0 +1,250 @@ +// Host resolution of a runtime terminal's `agentLaunch` request (U3). Wraps the +// shared host-state derivation and launch boundary for the terminal-create +// surfaces (terminal.create, session.tabs.createTerminal): it injects on-demand +// stock detection and the target home per execution host, marks workspace trust +// through the boundary's pre-admission preflight hook (driven by the resolved +// policy.preflightTrust, best-effort), and maps the admitted plan to the terminal +// option fields the spawn path consumes. The client's command/env/launchConfig/ +// launchAgent are IGNORED here — this is a security boundary on the untrusted RPC +// surface: only the host-resolved plan spawns. Electron-free and injection-based +// so it is unit-testable. + +import type { BuiltInTuiAgent, GlobalSettings, Repo } from '../../shared/types' +import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract' +import type { + AgentLaunchInput, + AgentLaunchSpawnOutcome, + AgentLaunchSpawnRequest +} from '../../shared/agent-launch-spawn-request' +import type { + AgentLaunchSnapshot, + LaunchIntent, + ResolvedAgentLaunch +} from '../../shared/agent-launch-host-contract' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import { + deriveAgentLaunchHostState, + type AgentLaunchHostDescriptor +} from '../agent-launch/agent-launch-host-state' +import { resolveAgentLaunchSpawn } from '../agent-launch/agent-launch-spawn' +import { resolveResumeLaunchIngest } from '../agent-launch/agent-launch-resume-ingest' +import type { AgentSessionRecordStore } from '../agent-launch/agent-session-record-store' +import type { resolveAgentLaunch } from '../agent-launch/resolve-agent-launch' +import type { + AgentLaunchBoundary, + AuthenticatedClientKind +} from '../agent-launch/agent-launch-boundary' +import { mapClientKindToLaunchClient } from '../agent-launch/agent-launch-boundary' +import type { AdmissionPrincipal } from '../agent-launch/agent-launch-admission-store' + +/** The host-resolved fields a terminal spawn consumes. The launchAgent is the + * BUILT-IN base (expectedProcess/telemetry are built-in-keyed); the requested + * identity travels in the receipt. */ +export type ResolvedTerminalLaunchFields = { + command: string + env?: Record + launchConfig: SleepingAgentLaunchConfig + launchAgent: BuiltInTuiAgent + launchToken: string + startupCommandDelivery?: StartupCommandDelivery + /** Post-ready prompt delivery for a host-spawned terminal (U7): a stdin-after- + * start followup is submitted; a no-native-affordance draft is pasted + * unsubmitted. Command-deliverable launches (argv/flag/env) carry neither. + * Present only when the resolved plan retained post-ready text — the host owns + * delivery via the readiness writers, never the client. */ + postReadyPrompt?: ResolvedTerminalPostReadyPrompt +} + +export type ResolvedTerminalPostReadyPrompt = { + expectedProcess: string + followupPrompt?: string + draftPrompt?: string +} + +/** A pre-spawn typed failure/rejection: NO terminal is created and — for the RPC + * surfaces — this is a successful response, not an error envelope. */ +export type TerminalAgentLaunchFailure = Extract< + AgentLaunchSpawnOutcome, + { status: 'failed' | 'rejected' } +> + +export type TerminalAgentLaunchResolution = + | { + kind: 'resolved' + fields: ResolvedTerminalLaunchFields + admissionToken: string + receipt: AgentLaunchReceipt + } + | { kind: 'failed'; outcome: TerminalAgentLaunchFailure } + +export type TerminalAgentLaunchDeps = { + boundary: AgentLaunchBoundary + getSettings: () => GlobalSettings + getCatalogRevision: () => number + detectStockBaseAgents: ( + descriptor: AgentLaunchHostDescriptor + ) => Promise + resolveTargetHomePath: (descriptor: AgentLaunchHostDescriptor) => Promise + /** Best-effort workspace trust for the resolved base agent, run as the + * boundary's pre-admission preflight. Must not throw for a routine no-trust + * agent; a throw maps to trust_preflight_failed with no admission record. */ + markWorkspaceTrusted: (launch: ResolvedAgentLaunch) => Promise | void + /** The host-private record store a resume/fork request resolves against. */ + sessionRecordStore: AgentSessionRecordStore + /** Injectable total resolver for tests; defaults to the real one. */ + resolve?: typeof resolveAgentLaunch +} + +export type TerminalAgentLaunchArgs = { + /** A fresh selection launch or a provider-session resume/fork by session key. */ + request: AgentLaunchInput + clientKind: AuthenticatedClientKind + descriptor: AgentLaunchHostDescriptor + scope: string + worktreePath: string | null + repoPath: string | null + /** Host-trusted repo overrides for a source-control-recipe sourceRecord lookup + * (U7). Derived from the launch's target workspace by the runtime caller, never + * client-supplied; absent falls back to the global recipe. */ + recipeRepo?: Pick | null + principal: AdmissionPrincipal +} + +/** The resume-specific launch inputs merged with host-context target/variables. */ +type TerminalSpawnInput = { + request: AgentLaunchSpawnRequest + intent: LaunchIntent + persistedSnapshot?: AgentLaunchSnapshot + resumeProviderSession?: AgentProviderSessionMetadata +} + +/** Map the client agentLaunch input to the spawn input: a resume/fork loads the + * private record by session key (never forwarding a client launch config, so + * mobile/paired legacy replay finds no record → invalid_launch_snapshot); a fresh + * selection builds an interactive intent host-side. */ +function resolveTerminalSpawnInput( + args: TerminalAgentLaunchArgs, + sessionRecordStore: AgentSessionRecordStore +): + | { ok: true; input: TerminalSpawnInput } + | { ok: false; failure: { code: 'invalid_launch_snapshot' } } { + const client = mapClientKindToLaunchClient(args.clientKind) + if ('resume' in args.request) { + // No legacy context is forwarded on this untrusted RPC surface, so opaque + // legacy replay never resolves here — a legacy record fails closed, exactly + // as the migration rules require for mobile/paired-initiated resumes. + const ingest = resolveResumeLaunchIngest( + { resume: args.request.resume, client }, + sessionRecordStore + ) + if (!ingest.ok || ingest.kind !== 'snapshot') { + return { ok: false, failure: { code: 'invalid_launch_snapshot' } } + } + return { + ok: true, + input: { + request: ingest.request, + intent: ingest.intent, + persistedSnapshot: ingest.persistedSnapshot, + resumeProviderSession: ingest.resumeProviderSession + } + } + } + if ('vaultResume' in args.request) { + // AI Vault resume bypasses the resolver (like legacy replay): the runtime + // intercepts it upstream in resolveWorkspaceAgentLaunch (copy → the dedicated + // command method, resume → host-assembled bypass). Reaching the resolver means + // a misroute, so fail closed rather than treating it as a fresh selection. + return { ok: false, failure: { code: 'invalid_launch_snapshot' } } + } + return { + ok: true, + input: { request: args.request, intent: { kind: 'interactive', client } } + } +} + +/** Resolve a terminal `agentLaunch` request into host-resolved spawn fields plus + * the admission token (settle after registration) and receipt, or a typed + * failure. Creates no terminal: the caller owns spawning + settlement. */ +export async function resolveTerminalAgentLaunch( + deps: TerminalAgentLaunchDeps, + args: TerminalAgentLaunchArgs +): Promise { + const hostState = await deriveAgentLaunchHostState( + { + getSettings: deps.getSettings, + getCatalogRevision: deps.getCatalogRevision, + detectStockBaseAgents: deps.detectStockBaseAgents, + resolveTargetHomePath: deps.resolveTargetHomePath + }, + args.descriptor, + { worktreePath: args.worktreePath, repoPath: args.repoPath } + ) + const spawnInput = resolveTerminalSpawnInput(args, deps.sessionRecordStore) + if (!spawnInput.ok) { + return { kind: 'failed', outcome: { status: 'failed', failure: spawnInput.failure } } + } + const resolution = await resolveAgentLaunchSpawn( + { + getSettings: hostState.getSettings, + getCatalogRevision: hostState.getCatalogRevision, + boundary: deps.boundary, + preflight: deps.markWorkspaceTrusted, + ...(deps.resolve ? { resolve: deps.resolve } : {}) + }, + { + request: spawnInput.input.request, + intent: spawnInput.input.intent, + target: hostState.target, + variables: hostState.variables, + scope: args.scope, + principal: args.principal, + ...(args.recipeRepo !== undefined ? { recipeRepo: args.recipeRepo } : {}), + ...(spawnInput.input.persistedSnapshot + ? { persistedSnapshot: spawnInput.input.persistedSnapshot } + : {}), + ...(spawnInput.input.resumeProviderSession + ? { resumeProviderSession: spawnInput.input.resumeProviderSession } + : {}) + } + ) + if (!resolution.ok) { + return { + kind: 'failed', + outcome: + 'failure' in resolution + ? { status: 'failed', failure: resolution.failure } + : { status: 'rejected', requestError: resolution.requestError } + } + } + return { + kind: 'resolved', + admissionToken: resolution.receipt.launchToken, + receipt: resolution.receipt, + fields: { + command: resolution.plan.launchCommand, + ...(resolution.plan.env ? { env: resolution.plan.env } : {}), + launchConfig: resolution.plan.launchConfig, + launchAgent: resolution.receipt.baseAgent, + launchToken: resolution.receipt.launchToken, + ...(resolution.plan.startupCommandDelivery !== undefined + ? { startupCommandDelivery: resolution.plan.startupCommandDelivery } + : {}), + ...(resolution.plan.followupPrompt || resolution.plan.draftPrompt + ? { + postReadyPrompt: { + expectedProcess: resolution.plan.expectedProcess, + ...(resolution.plan.followupPrompt + ? { followupPrompt: resolution.plan.followupPrompt } + : {}), + ...(resolution.plan.draftPrompt ? { draftPrompt: resolution.plan.draftPrompt } : {}) + } + } + : {}) + } + } +} diff --git a/src/main/telemetry/agent-started-telemetry.test.ts b/src/main/telemetry/agent-started-telemetry.test.ts new file mode 100644 index 00000000000..b4e705e180e --- /dev/null +++ b/src/main/telemetry/agent-started-telemetry.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartedAttribution } from './agent-started-telemetry' + +describe('buildAgentStartedAttribution (oracle 17)', () => { + const surface = { launch_source: 'sidebar', request_kind: 'new' } as const + + it('carries a host-derived custom marker through with the base kind', () => { + const attribution = buildAgentStartedAttribution({ + ...surface, + agent_kind: 'codex', + used_custom_agent: true + }) + expect(attribution).toEqual({ + agent_kind: 'codex', + launch_source: 'sidebar', + request_kind: 'new', + used_custom_agent: true + }) + }) + + it('treats an absent used_custom_agent as not custom (built-in / legacy)', () => { + const attribution = buildAgentStartedAttribution({ ...surface, agent_kind: 'claude-code' }) + expect(attribution?.used_custom_agent).toBe(false) + }) + + it('treats a non-boolean used_custom_agent as not custom (only === true wins)', () => { + const attribution = buildAgentStartedAttribution({ + ...surface, + agent_kind: 'claude-code', + used_custom_agent: 'true' + }) + expect(attribution?.used_custom_agent).toBe(false) + }) + + it('drops the event when a surface field is missing', () => { + expect( + buildAgentStartedAttribution({ agent_kind: 'codex', request_kind: 'new' }) + ).toBeNull() + expect( + buildAgentStartedAttribution({ agent_kind: 'codex', launch_source: 'sidebar' }) + ).toBeNull() + }) + + it('drops the event for an out-of-enum agent_kind or launch_source', () => { + expect( + buildAgentStartedAttribution({ ...surface, agent_kind: 'made-up-kind' }) + ).toBeNull() + expect( + buildAgentStartedAttribution({ + agent_kind: 'codex', + launch_source: 'not-a-source', + request_kind: 'new' + }) + ).toBeNull() + }) + + it('drops the event when nothing was threaded', () => { + expect(buildAgentStartedAttribution(undefined)).toBeNull() + }) +}) diff --git a/src/main/telemetry/agent-started-telemetry.ts b/src/main/telemetry/agent-started-telemetry.ts new file mode 100644 index 00000000000..f282a32420b --- /dev/null +++ b/src/main/telemetry/agent-started-telemetry.ts @@ -0,0 +1,51 @@ +// Single builder for the `agent_started` attribution fields, shared by both PTY +// emitters (the runtime-owned CLI/create controller spawn and the renderer +// `pty:spawn` handler). Oracle 17: agent_kind + used_custom_agent are host- +// derived from the validated launch snapshot/receipt on a resolved launch — the +// caller overwrites the client-threaded values before spawn, so a spoofed client +// `agent_kind` never reaches the wire. launch_source/request_kind stay surface- +// owned. The event carries NO id/label/command/argv/env/path (the `.strict()` +// schema is the enforcement point); this marker is the only custom-launch signal. + +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../shared/telemetry-events' +import type { EventProps } from '../../shared/telemetry-events' + +/** The emit input threaded through the spawn args. Loosely typed because it + * crosses the IPC/controller boundary; every field is re-validated here. */ +export type AgentStartedTelemetryInput = { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + used_custom_agent?: unknown +} + +type AgentStartedAttribution = Pick< + EventProps<'agent_started'>, + 'agent_kind' | 'launch_source' | 'request_kind' | 'used_custom_agent' +> + +/** Validate the threaded telemetry into the closed-enum agent_started fields, or + * null to skip the event. Returns null when any required field is missing or + * outside its enum — a malformed/spoofed payload drops the event rather than + * poisoning it. used_custom_agent is absent/invalid => false (built-in, + * safe-fallback, and legacy-opaque launches are never custom). */ +export function buildAgentStartedAttribution( + telemetry: AgentStartedTelemetryInput | undefined +): AgentStartedAttribution | null { + const agentKindParse = agentKindSchema.safeParse(telemetry?.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(telemetry?.launch_source) + const requestKindParse = requestKindSchema.safeParse(telemetry?.request_kind) + if (!agentKindParse.success || !launchSourceParse.success || !requestKindParse.success) { + return null + } + return { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + used_custom_agent: telemetry?.used_custom_agent === true + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts index 44c76a74fee..7c5f10068a3 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -5,7 +5,7 @@ import { type DashboardCard, type DashboardWorkspace } from '../../../../shared/dashboard-snapshot' -import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isBuiltInTuiAgent, isTuiAgent } from '../../../../shared/tui-agent-config' import { filterEnabledTuiAgents, TUI_AGENT_AUTO_PICK_ORDER @@ -119,7 +119,7 @@ export function buildDashboardWorktreeLaunchOptions( ) const preferred = state.settings?.defaultTuiAgent result[worktreeId] = - preferred && preferred !== 'blank' && enabled.includes(preferred) + preferred && preferred !== 'blank' && isBuiltInTuiAgent(preferred) && enabled.includes(preferred) ? [preferred, ...enabled.filter((agent) => agent !== preferred)] : enabled } diff --git a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts index bc29e08b14e..73be714de0b 100644 --- a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts @@ -120,11 +120,23 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp note, quickAgent: agent, autoRenameBranchFromWork: settings?.autoRenameBranchFromWork, - agentCmdOverrides: settings?.agentCmdOverrides, + agentCmdOverrides: settings?.agentCmdOverrides + ? Object.fromEntries( + Object.entries(settings.agentCmdOverrides).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) + : undefined, agentArgs: agent ? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs) : undefined, - agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, + agentEnv: agent + ? Object.fromEntries( + Object.entries( + resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) ?? {} + ).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) + : undefined, sessionOptions: agent ? resolveInitialNativeChatSessionOptions( { diff --git a/src/shared/agent-args-tokenizer.test.ts b/src/shared/agent-args-tokenizer.test.ts new file mode 100644 index 00000000000..f1c19ea47d5 --- /dev/null +++ b/src/shared/agent-args-tokenizer.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + canonicalizeAgentArgsLineEndings, + serializeAgentArgsTokens, + tokenizeAgentArgsTemplate +} from './agent-args-tokenizer' + +function tokens(template: string): string[] { + const result = tokenizeAgentArgsTemplate(template) + if (!result.ok) { + throw new Error(`expected ok, got ${result.reason} at ${result.index}`) + } + return result.tokens +} + +function failure(template: string): string { + const result = tokenizeAgentArgsTemplate(template) + if (result.ok) { + throw new Error(`expected failure, got tokens ${JSON.stringify(result.tokens)}`) + } + return result.reason +} + +describe('tokenizeAgentArgsTemplate', () => { + it('splits on unquoted spaces, tabs, and newlines', () => { + expect(tokens('--model x --safe')).toEqual(['--model', 'x', '--safe']) + expect(tokens('--model x\n--safe')).toEqual(['--model', 'x', '--safe']) + expect(tokens('--model\tx\r\n--safe')).toEqual(['--model', 'x', '--safe']) + expect(tokens(' leading and trailing ')).toEqual(['leading', 'and', 'trailing']) + }) + + it('returns no tokens for empty or whitespace-only templates', () => { + expect(tokens('')).toEqual([]) + expect(tokens(' \n\t ')).toEqual([]) + }) + + it('groups quoted text and keeps spaces and = inside one token', () => { + expect(tokens('--name "hello world"')).toEqual(['--name', 'hello world']) + expect(tokens("--name 'hello world'")).toEqual(['--name', 'hello world']) + expect(tokens('--opt "KEY=some value"')).toEqual(['--opt', 'KEY=some value']) + }) + + it('retains empty quoted tokens and collapses an empty-quote run to one empty token', () => { + expect(tokens('--flag ""')).toEqual(['--flag', '']) + expect(tokens("''")).toEqual(['']) + expect(tokens(`""''""`)).toEqual(['']) + }) + + it('concatenates adjacent segments not separated by unquoted whitespace', () => { + expect(tokens('a"b"c')).toEqual(['abc']) + expect(tokens(`pre'mid'post`)).toEqual(['premidpost']) + expect(tokens('--x="quoted val"')).toEqual(['--x=quoted val']) + }) + + it('keeps backslashes literal except before whitespace, quotes, or backslash', () => { + expect(tokens('C:\\Users\\me')).toEqual(['C:\\Users\\me']) + expect(tokens('foo\\ bar')).toEqual(['foo bar']) + expect(tokens('a\\"b')).toEqual(['a"b']) + expect(tokens('a\\\\b')).toEqual(['a\\b']) + expect(tokens('esc\\aped')).toEqual(['esc\\aped']) + }) + + it('accepts a trailing literal backslash', () => { + expect(tokens('C:\\dir\\')).toEqual(['C:\\dir\\']) + expect(tokens('lone \\')).toEqual(['lone', '\\']) + }) + + it('decodes only \\" and \\\\ inside double quotes; single quotes are fully literal', () => { + expect(tokens('"a\\"b"')).toEqual(['a"b']) + expect(tokens('"a\\\\b"')).toEqual(['a\\b']) + expect(tokens('"C:\\Users\\me"')).toEqual(['C:\\Users\\me']) + expect(tokens("'a\\nb'")).toEqual(['a\\nb']) + }) + + it('treats shell operators, globs, and expansions as data', () => { + expect(tokens('a&&b || c | d > e < f $(g) `h` $VAR %VAR% *.ts')).toEqual([ + 'a&&b', + '||', + 'c', + '|', + 'd', + '>', + 'e', + '<', + 'f', + '$(g)', + '`h`', + '$VAR', + '%VAR%', + '*.ts' + ]) + }) + + it('rejects unterminated quotes', () => { + expect(failure('"open')).toBe('unterminated_quote') + expect(failure("'open")).toBe('unterminated_quote') + expect(failure('ok "open')).toBe('unterminated_quote') + }) + + it('rejects line breaks inside either quote form', () => { + expect(failure('"a\nb"')).toBe('quoted_line_break') + expect(failure("'a\nb'")).toBe('quoted_line_break') + expect(failure('"a\rb"')).toBe('quoted_line_break') + }) + + it('rejects disallowed control characters', () => { + expect(failure('a\0b')).toBe('control_char') + expect(failure('a\x07b')).toBe('control_char') + expect(failure('a\x7fb')).toBe('control_char') + expect(failure('a\u0085b')).toBe('control_char') + expect(failure('"a\tb"')).toBe('control_char') + }) +}) + +describe('canonicalizeAgentArgsLineEndings', () => { + it('normalizes CRLF and bare CR to LF', () => { + expect(canonicalizeAgentArgsLineEndings('a\r\nb\rc\nd')).toBe('a\nb\nc\nd') + }) +}) + +describe('serializeAgentArgsTokens', () => { + it('round-trips through the tokenizer', () => { + const cases: string[][] = [ + ['--model', 'x', '--safe'], + ['hello world', ''], + ['C:\\Users\\me', 'a"b', "single'quote"], + ['KEY=some value', '$(not expanded)', '%VAR%'] + ] + for (const original of cases) { + const serialized = serializeAgentArgsTokens(original) + expect(tokens(serialized)).toEqual(original) + } + }) +}) diff --git a/src/shared/agent-args-tokenizer.ts b/src/shared/agent-args-tokenizer.ts new file mode 100644 index 00000000000..46cf52eeaec --- /dev/null +++ b/src/shared/agent-args-tokenizer.ts @@ -0,0 +1,192 @@ +// Shell-independent v1 grammar for custom-agent args templates. One grammar +// serves every target shell so a stored template means the same argv on POSIX, +// PowerShell, WSL, and SSH; per-shell encoding happens later in the startup +// planner. Deliberately implemented fresh: `tokenizeCustomCommandTemplate` in +// commit-message-prompt.ts backslash-escapes any following character, which +// would corrupt Windows paths like C:\Users\me. +// +// Grammar: +// - Unquoted ASCII whitespace (space, tab, CR, LF) separates tokens; the +// multiline editor is real — one or more arguments per line. +// - Single/double quotes group same-line text; a CR/LF inside either quote form +// is invalid because not every target shell can represent it consistently. +// - Adjacent segments not separated by unquoted whitespace concatenate into one +// token (`a"b"c` -> `abc`); a run of only empty quotes yields one empty token. +// - Outside quotes, backslash escapes only whitespace, quote, or backslash; +// before any other character it stays literal (preserves C:\Users\me). A +// trailing literal backslash is valid. +// - Inside double quotes only `\"` and `\\` decode; single-quoted content is +// fully literal. +// - Shell operators, substitution, redirection, globs, and env expansion have +// no special meaning — every token is data. + +export type AgentArgsTokenizeFailureReason = + | 'unterminated_quote' + | 'quoted_line_break' + | 'control_char' + +export type AgentArgsTokenizeResult = + | { ok: true; tokens: string[] } + | { ok: false; reason: AgentArgsTokenizeFailureReason; index: number } + +const SEPARATORS = new Set([' ', '\t', '\r', '\n']) + +function isDisallowedControl(char: string): boolean { + const code = char.charCodeAt(0) + // NUL, C0 (minus tab/CR/LF which are separators outside quotes), DEL, C1. + if (code === 0x00 || code === 0x7f) { + return true + } + if (code < 0x20) { + return char !== '\t' && char !== '\r' && char !== '\n' + } + return code >= 0x80 && code <= 0x9f +} + +export function tokenizeAgentArgsTemplate(template: string): AgentArgsTokenizeResult { + const tokens: string[] = [] + let current = '' + let hasCurrent = false + let i = 0 + const length = template.length + + while (i < length) { + const char = template[i] + + if (SEPARATORS.has(char)) { + if (hasCurrent) { + tokens.push(current) + current = '' + hasCurrent = false + } + i += 1 + continue + } + + if (isDisallowedControl(char)) { + return { ok: false, reason: 'control_char', index: i } + } + + if (char === "'") { + const start = i + i += 1 + hasCurrent = true + while (i < length) { + const inner = template[i] + if (inner === "'") { + break + } + if (inner === '\r' || inner === '\n') { + return { ok: false, reason: 'quoted_line_break', index: i } + } + if (inner === '\t' || isDisallowedControl(inner)) { + return { ok: false, reason: 'control_char', index: i } + } + current += inner + i += 1 + } + if (i >= length) { + return { ok: false, reason: 'unterminated_quote', index: start } + } + i += 1 + continue + } + + if (char === '"') { + const start = i + i += 1 + hasCurrent = true + let closed = false + while (i < length) { + const inner = template[i] + if (inner === '"') { + closed = true + i += 1 + break + } + if (inner === '\r' || inner === '\n') { + return { ok: false, reason: 'quoted_line_break', index: i } + } + if (inner === '\t' || isDisallowedControl(inner)) { + return { ok: false, reason: 'control_char', index: i } + } + if (inner === '\\' && i + 1 < length) { + const next = template[i + 1] + if (next === '"' || next === '\\') { + current += next + i += 2 + continue + } + } + current += inner + i += 1 + } + if (!closed) { + return { ok: false, reason: 'unterminated_quote', index: start } + } + continue + } + + if (char === '\\') { + const next = i + 1 < length ? template[i + 1] : null + if ( + next !== null && + (SEPARATORS.has(next) || next === '"' || next === "'" || next === '\\') + ) { + current += next + hasCurrent = true + i += 2 + continue + } + // Literal backslash (including a trailing one): preserves Windows paths. + current += char + hasCurrent = true + i += 1 + continue + } + + current += char + hasCurrent = true + i += 1 + } + + if (hasCurrent) { + tokens.push(current) + } + return { ok: true, tokens } +} + +export type AgentArgsValidationResult = + | { ok: true } + | { ok: false; reason: AgentArgsTokenizeFailureReason; index: number } + +export function validateAgentArgsTemplate(template: string): AgentArgsValidationResult { + const result = tokenizeAgentArgsTemplate(template) + if (!result.ok) { + return { ok: false, reason: result.reason, index: result.index } + } + return { ok: true } +} + +/** CRLF/CR normalize to LF only on an explicit save; reads never rewrite. */ +export function canonicalizeAgentArgsLineEndings(template: string): string { + return template.replace(/\r\n?/g, '\n') +} + +const BARE_TOKEN_SAFE_RE = /^[^\s"'\\]+$/u + +/** Canonical v1 serialization of an argv token list: bare where possible, else a + * double-quoted form using only the `\"` / `\\` escapes the grammar decodes. */ +export function serializeAgentArgsTokens(tokens: readonly string[]): string { + return tokens + .map((token) => { + if (token.length === 0) { + return '""' + } + if (BARE_TOKEN_SAFE_RE.test(token)) { + return token + } + return `"${token.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + }) + .join(' ') +} diff --git a/src/shared/agent-catalog-normalization.ts b/src/shared/agent-catalog-normalization.ts new file mode 100644 index 00000000000..ad960973fe2 --- /dev/null +++ b/src/shared/agent-catalog-normalization.ts @@ -0,0 +1,208 @@ +// Normalized agent catalog: one immutable, fail-closed lookup built from persisted +// settings arrays. Invalid rows become repair/corrupt records — no field is +// dropped, truncated, or rewritten to make a row launchable. Per-row validation +// lives in the re-exported row-validation sibling. + +import type { CustomTuiAgent, CustomTuiAgentId, DeletedCustomTuiAgent, TuiAgent } from './types' +import { isBuiltInTuiAgent } from './tui-agent-config' +import { isCustomTuiAgentId } from './custom-tui-agent-identity' +import { + measureRawBytes, + normalizeTombstone, + validateLiveRow, + type CorruptCatalogRow, + type LiveRowValidation +} from './agent-catalog-row-validation' + +export type { CorruptCatalogRow } from './agent-catalog-row-validation' + +export type AgentCatalog = { + /** Live, fully valid custom agents in persisted creation order. */ + readonly liveCustomAgents: readonly CustomTuiAgent[] + readonly liveById: ReadonlyMap + /** Live rows whose id/base are valid and unique but another field needs repair. + * Addressable and visible, never launchable. */ + readonly repairRequiredById: ReadonlyMap + /** Rows that cannot be addressed unambiguously by id (malformed or duplicate). */ + readonly corruptRows: readonly CorruptCatalogRow[] + readonly tombstonesById: ReadonlyMap + readonly disabledAgents: ReadonlySet + readonly defaultAgent: TuiAgent | 'auto' | 'blank' | null +} + +export type NormalizedAgentCatalogInput = { + customTuiAgents?: unknown + deletedCustomTuiAgents?: unknown + disabledTuiAgents?: unknown + defaultTuiAgent?: unknown +} + +export type NormalizeAgentCatalogResult = { + catalog: AgentCatalog + /** True when the default was repaired to null (unknown custom id with neither + * definition nor tombstone, or base-disabled default). */ + defaultRepairedToNull: boolean +} + +/** Build one immutable lookup over persisted catalog state. Fail-closed, not lossy: + * invalid rows become repair/corrupt records — no field is dropped, truncated, or + * rewritten to make a row launchable. */ +export function normalizeAgentCatalog( + input: NormalizedAgentCatalogInput +): NormalizeAgentCatalogResult { + const tombstonesById = new Map() + if (Array.isArray(input.deletedCustomTuiAgents)) { + for (const raw of input.deletedCustomTuiAgents) { + const tombstone = normalizeTombstone(raw) + if (tombstone && !tombstonesById.has(tombstone.id)) { + tombstonesById.set(tombstone.id, tombstone) + } + } + } + + const liveById = new Map() + const liveCustomAgents: CustomTuiAgent[] = [] + const repairRequiredById = new Map() + const corruptRows: CorruptCatalogRow[] = [] + const validRowsById = new Map() + const rows: LiveRowValidation[] = [] + + if (Array.isArray(input.customTuiAgents)) { + input.customTuiAgents.forEach((raw, index) => { + const row = validateLiveRow(raw, index) + rows.push(row) + const id = + row.kind === 'valid' ? row.definition.id : row.row.id !== undefined ? row.row.id : null + if (id !== null) { + const group = validRowsById.get(id) + if (group) { + group.push(row) + } else { + validRowsById.set(id, [row]) + } + } + }) + } + + // Duplicate live ids quarantine the whole group: removing one duplicate must + // never silently make another authoritative (repair is one atomic group choice). + const duplicateIds = new Set() + for (const [id, group] of validRowsById) { + if (group.length > 1) { + duplicateIds.add(id) + } + } + + for (const row of rows) { + if (row.kind === 'valid') { + const id = row.definition.id + if (duplicateIds.has(id)) { + corruptRows.push({ + id, + baseAgent: row.definition.baseAgent, + label: row.definition.label, + issues: [{ field: 'identity', reason: 'duplicate_id' }], + rawBytes: measureRawBytes(row.definition), + physicalIndex: liveCustomAgents.length + corruptRows.length, + raw: row.definition + }) + continue + } + // Ids are never reused: a same-id tombstone wins so deletion cannot + // resurrect after corrupted/legacy merges. + if (tombstonesById.has(id)) { + continue + } + liveById.set(id, row.definition) + liveCustomAgents.push(row.definition) + continue + } + if (row.kind === 'repair-required') { + const id = row.row.id + if (id !== undefined && duplicateIds.has(id)) { + corruptRows.push({ + ...row.row, + issues: [...row.row.issues, { field: 'identity', reason: 'duplicate_id' }] + }) + continue + } + if (id !== undefined && tombstonesById.has(id)) { + continue + } + if (id !== undefined) { + repairRequiredById.set(id, row.row) + } else { + corruptRows.push(row.row) + } + continue + } + corruptRows.push(row.row) + } + + const disabledAgents = new Set() + if (Array.isArray(input.disabledTuiAgents)) { + for (const item of input.disabledTuiAgents) { + if (isBuiltInTuiAgent(item)) { + disabledAgents.add(item) + continue + } + // Only known built-ins or live custom ids belong in the disabled list; + // repair-required rows keep their disabled state so repair cannot enable. + if (isCustomTuiAgentId(item) && (liveById.has(item) || repairRequiredById.has(item))) { + disabledAgents.add(item) + } + } + } + + let defaultAgent: TuiAgent | 'auto' | 'blank' | null = null + let defaultRepairedToNull = false + const rawDefault = input.defaultTuiAgent + if (rawDefault === 'auto' || rawDefault === 'blank' || rawDefault === null) { + defaultAgent = rawDefault as 'auto' | 'blank' | null + } else if (isBuiltInTuiAgent(rawDefault)) { + defaultAgent = rawDefault + } else if (isCustomTuiAgentId(rawDefault)) { + const live = liveById.get(rawDefault) ?? null + const repair = repairRequiredById.get(rawDefault) ?? null + const tombstone = tombstonesById.get(rawDefault) ?? null + const provenBase = live?.baseAgent ?? repair?.baseAgent ?? tombstone?.baseAgent ?? null + if (provenBase === null) { + // Unknown custom id with neither definition nor tombstone: id syntax alone + // grants no authority, so the default repairs to null (needs attention). + defaultAgent = null + defaultRepairedToNull = true + } else if (disabledAgents.has(provenBase)) { + // Disabling the base repairs a base/derivative default to null because no + // fallback is launchable. + defaultAgent = null + defaultRepairedToNull = true + } else { + // A live disabled/tombstoned custom default remains a validated stored + // reference for attended safe fallback. + defaultAgent = rawDefault + } + } else { + defaultAgent = null + if (rawDefault !== undefined) { + defaultRepairedToNull = true + } + } + if (isBuiltInTuiAgent(defaultAgent) && disabledAgents.has(defaultAgent)) { + // Built-in default whose base is disabled: same repair rule as derivatives. + defaultAgent = null + defaultRepairedToNull = true + } + + return { + catalog: { + liveCustomAgents, + liveById, + repairRequiredById, + corruptRows, + tombstonesById, + disabledAgents, + defaultAgent + }, + defaultRepairedToNull + } +} diff --git a/src/shared/agent-catalog-row-validation.ts b/src/shared/agent-catalog-row-validation.ts new file mode 100644 index 00000000000..f8d8398e8a1 --- /dev/null +++ b/src/shared/agent-catalog-row-validation.ts @@ -0,0 +1,165 @@ +// Per-row validation for the persisted custom-agent catalog: classify each raw +// live record as valid / repair-required / corrupt, and normalize tombstones. +// Fail-closed — a row is only launchable when every field is independently valid. + +import type { + BuiltInTuiAgent, + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent +} from './types' +import { parseCustomTuiAgentId } from './custom-tui-agent-identity' +import { + canonicalizeCommandOverride, + normalizeAgentLabelText, + utf8ByteLength, + validateAgentArgs, + validateAgentLabel, + validateCommandOverride, + validateCustomAgentEnv, + type AgentFieldIssue +} from './custom-tui-agent-fields' + +export type CorruptCatalogRow = { + /** Present only when independently canonical and safe to display/address. */ + id?: CustomTuiAgentId + baseAgent?: BuiltInTuiAgent + /** Validated label or null when the persisted label itself is unsafe. */ + label: string | null + issues: AgentFieldIssue[] + /** UTF-8 JSON byte size of the raw physical record. */ + rawBytes: number + /** Index of the physical record in the persisted live array. */ + physicalIndex: number + /** The raw persisted record, retained for local repair only; never projected. */ + raw: unknown +} + +export type LiveRowValidation = + | { kind: 'valid'; definition: CustomTuiAgent } + | { kind: 'repair-required'; row: CorruptCatalogRow } + | { kind: 'corrupt'; row: CorruptCatalogRow } + +export function measureRawBytes(value: unknown): number { + try { + return utf8ByteLength(JSON.stringify(value) ?? 'null') + } catch { + return 0 + } +} + +export function normalizeTombstone(value: unknown): DeletedCustomTuiAgent | null { + if (typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + const id = record.id + const parsed = parseCustomTuiAgentId(id) + // A tombstone is launch authority only when its id/base pair is canonical and agrees. + if (!parsed || record.baseAgent !== parsed.baseAgent) { + return null + } + const label = typeof record.label === 'string' ? record.label : '' + const deletedAt = + typeof record.deletedAt === 'number' && Number.isFinite(record.deletedAt) ? record.deletedAt : 0 + return { + id: id as CustomTuiAgentId, + baseAgent: parsed.baseAgent, + label, + deletedAt + } +} + +export function validateLiveRow(value: unknown, physicalIndex: number): LiveRowValidation { + const rawBytes = measureRawBytes(value) + if (typeof value !== 'object' || value === null) { + return { + kind: 'corrupt', + row: { + label: null, + issues: [{ field: 'identity', reason: 'empty' }], + rawBytes, + physicalIndex, + raw: value + } + } + } + const record = value as Record + const issues: AgentFieldIssue[] = [] + + const parsed = parseCustomTuiAgentId(record.id) + const idOk = parsed !== null + if (!idOk) { + issues.push({ field: 'identity', reason: 'empty' }) + } + const baseOk = idOk && record.baseAgent === parsed.baseAgent + if (idOk && !baseOk) { + issues.push({ field: 'identity', reason: 'identity_mismatch' }) + } + + const labelIssue = validateAgentLabel(record.label) + if (labelIssue) { + issues.push(labelIssue) + } + const commandIssue = validateCommandOverride(record.commandOverride) + if (commandIssue) { + issues.push(commandIssue) + } + const argsIssue = validateAgentArgs(record.args ?? '') + if (argsIssue) { + issues.push(argsIssue) + } + issues.push(...validateCustomAgentEnv(record.env ?? {})) + + const safeLabel = + typeof record.label === 'string' && !validateAgentLabel(record.label) + ? normalizeAgentLabelText(record.label) + : null + + if (!idOk || !baseOk) { + return { + kind: 'corrupt', + row: { + ...(idOk ? { id: record.id as CustomTuiAgentId, baseAgent: parsed.baseAgent } : {}), + label: safeLabel, + issues, + rawBytes, + physicalIndex, + raw: value + } + } + } + + if (issues.length > 0) { + return { + kind: 'repair-required', + row: { + id: record.id as CustomTuiAgentId, + baseAgent: parsed.baseAgent, + label: safeLabel, + issues, + rawBytes, + physicalIndex, + raw: value + } + } + } + + const env: Record = Object.create(null) as Record + for (const [key, envValue] of Object.entries(record.env as Record)) { + env[key] = envValue + } + const definition: CustomTuiAgent = { + id: record.id as CustomTuiAgentId, + baseAgent: parsed.baseAgent, + label: normalizeAgentLabelText(record.label as string), + ...(typeof record.commandOverride === 'string' && record.commandOverride.length > 0 + ? { commandOverride: canonicalizeCommandOverride(record.commandOverride) } + : {}), + args: typeof record.args === 'string' ? record.args : '', + env, + // Missing/invalid syncEnv normalizes to false (fail closed on env sharing). + syncEnv: record.syncEnv === true + } + return { kind: 'valid', definition } +} diff --git a/src/shared/agent-catalog-snapshot.ts b/src/shared/agent-catalog-snapshot.ts new file mode 100644 index 00000000000..cf78d71db17 --- /dev/null +++ b/src/shared/agent-catalog-snapshot.ts @@ -0,0 +1,210 @@ +// Versioned agent-catalog DTOs. The remote snapshot is an authoritative, +// revisioned full replacement (receivers replace, never merge) and is env-free: +// no custom env key or value may appear in any remote projection, cache, or +// mutation result. Local (Electron preload IPC only) shapes carry repair +// metadata and byte summaries, still never env values in list form. + +import type { + BuiltInTuiAgent, + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + TuiAgent +} from './types' +import type { + MAX_AGENT_CATALOG_PROJECTION_BYTES, + MAX_LOCAL_AGENT_CATALOG_BYTES +} from './custom-tui-agents' + +/** Local repair metadata only. It never contains raw field text or an env key/value. */ +export type AgentCatalogRepairIssue = { + field: 'identity' | 'baseAgent' | 'label' | 'commandOverride' | 'args' | 'env' + reason: + | 'empty' + | 'bounds' + | 'control_char' + | 'unterminated_quote' + | 'quoted_line_break' + | 'shell_operator' + | 'reserved_name' + | 'prototype_key' + | 'case_collision' + | 'env_total_bounds' + | 'duplicate_id' + | 'identity_mismatch' + envEntryIndex?: number +} + +export type SyncedCustomTuiAgent = + | (Omit & { + status: 'ready' + // Describes host launch capability; keys and values are never projected. + envState: 'none' | 'available' | 'withheld' + // Conservative remote UX hint; never identifies PATH or another env key. + availabilityCheck: 'baseline-detection' | 'host-preflight' + }) + | { + id: CustomTuiAgentId + baseAgent: BuiltInTuiAgent + // Null when the persisted label itself is unsafe; clients localize a generic fallback. + label: string | null + status: 'repair-required' + // Invalid raw command/args/env never enter a remote projection. + envState: 'none' + } + +export type AgentCatalogSnapshot = { + version: 1 + revision: number + defaultAgent: TuiAgent | 'auto' | 'blank' | null + disabledAgents: TuiAgent[] + customAgents: SyncedCustomTuiAgent[] + deletedCustomAgents: DeletedCustomTuiAgent[] +} + +export type AgentProjectionStatus = + | { status: 'ready'; bytes: number; maxBytes: typeof MAX_AGENT_CATALOG_PROJECTION_BYTES } + | { status: 'too-large'; bytes: number; maxBytes: typeof MAX_AGENT_CATALOG_PROJECTION_BYTES } + +export type LocalAgentCatalogStorageStatus = + | { status: 'ready'; bytes: number; maxBytes: typeof MAX_LOCAL_AGENT_CATALOG_BYTES } + | { status: 'too-large'; bytes: number; maxBytes: typeof MAX_LOCAL_AGENT_CATALOG_BYTES } + +export type LocalCustomTuiAgent = + | { + status: 'ready' + definition: Omit + envSummary: { entryCount: number; bytes: number } + availabilityReason: 'baseline-stock' | 'configured-executable' | 'custom-path' + } + | { + status: 'repair-required' + // Present only when each value is independently canonical and safe to display/address. + id?: CustomTuiAgentId + baseAgent?: BuiltInTuiAgent + label: string | null + // Opaque, local-only, and valid only with this snapshot revision. + repairToken: string + issues: AgentCatalogRepairIssue[] + rawBytes: number + draftAvailability: 'available' | 'too-large' + } + +// Local Electron preload IPC only; never registered as a runtime RPC result. +export type LocalAgentCatalogSnapshot = Omit & { + customAgents: LocalCustomTuiAgent[] + repairIssues: AgentCatalogRepairIssue[] + projection: AgentProjectionStatus + localStorage: LocalAgentCatalogStorageStatus +} + +export const MAX_LOCAL_AGENT_DRAFT_BYTES = 1_048_576 + +export type CustomAgentEditableFields = { + label: string + commandOverride: string | null + args: string + env: Record + syncEnv: boolean +} + +export type CustomAgentDraft = CustomAgentEditableFields + +export type BuiltInAgentEditableFields = { + commandOverride: string | null + args: string + env: Record +} + +export type LocalCustomAgentDraftResult = + | { + status: 'ready' + revision: number + draft: CustomAgentEditableFields + } + | { + status: 'too-large' + revision: number + bytes: number + maxBytes: typeof MAX_LOCAL_AGENT_DRAFT_BYTES + } + +export type AgentCatalogProjectionError = { + version: 1 + revision: number + code: 'agent_catalog_payload_too_large' + maxBytes: typeof MAX_AGENT_CATALOG_PROJECTION_BYTES +} + +export type AgentCatalogMutation = + | { kind: 'create'; baseAgent: BuiltInTuiAgent; draft: CustomAgentDraft } + | { kind: 'duplicate'; sourceAgent: TuiAgent; label: string } + | { kind: 'update-custom'; id: CustomTuiAgentId; changes: CustomAgentEditableFields } + | { + kind: 'delete-custom' + id: CustomTuiAgentId + // Only applied when this id is the current default at expectedRevision; ignored otherwise. + // `keep` (default) leaves the tombstoned id as the stored default for safe-fallback launches. + // `base` rebinds to the proven base harness; `auto` stores Auto; + // `clear` stores null so Settings prompts. + onDefault?: 'keep' | 'base' | 'auto' | 'clear' + } + | { kind: 'set-enabled'; agent: TuiAgent; enabled: boolean } + | { kind: 'set-default'; agent: TuiAgent | 'auto' | 'blank' } + | { + kind: 'repair-corrupt' + repairToken: string + action: + | { kind: 'discard' } + | { kind: 'replace'; baseAgent: BuiltInTuiAgent; draft: CustomAgentDraft } + } + | { + kind: 'resolve-duplicate-id' + duplicateId: CustomTuiAgentId + // Host requires this to cover the exact current duplicate group once each. + rows: readonly { + repairToken: string + action: + | { kind: 'keep-for-existing-references'; repairedDraft: CustomAgentDraft } + | { kind: 'discard' } + | { kind: 'replace'; baseAgent: BuiltInTuiAgent; draft: CustomAgentDraft } + }[] + } + | { kind: 'update-built-in'; agent: BuiltInTuiAgent; changes: BuiltInAgentEditableFields } + +export type AgentCatalogMutationRequest = { + expectedRevision: number + mutation: AgentCatalogMutation +} + +export type AgentCatalogMutationResult = + | { ok: true; revision: number; snapshot: LocalAgentCatalogSnapshot } + | { + ok: false + code: + | 'catalog_revision_conflict' + | 'duplicate_agent_label' + | 'invalid_agent_field' + | 'stale_agent_repair_token' + | 'agent_catalog_local_payload_too_large' + | 'agent_catalog_payload_too_large' + revision: number + // Present on conflict so the editor can refresh while preserving the draft. + snapshot?: LocalAgentCatalogSnapshot + field?: 'label' | 'commandOverride' | 'args' | 'env' + reason?: + | 'empty' + | 'bounds' + | 'reserved_name' + | 'prototype_key' + | 'case_collision' + | 'control_char' + | 'unterminated_quote' + | 'quoted_line_break' + | 'shell_operator' + | 'platform_ambiguous' + | 'duplicate_id' + | 'identity_mismatch' + | 'env_total_bounds' + envEntryIndex?: number + } diff --git a/src/shared/agent-launch-contract.ts b/src/shared/agent-launch-contract.ts new file mode 100644 index 00000000000..a43d8a3f723 --- /dev/null +++ b/src/shared/agent-launch-contract.ts @@ -0,0 +1,124 @@ +// Client-safe agent-launch contracts: typed notices, launch-attempt failures, +// request/control-plane errors, and the launch receipt. These may cross RPC to +// mobile/paired clients. They never carry env keys/values, full argv, paths, +// prompts, or labels beyond the requested agent's display label in notices. +// Host-only resolution/request types live in agent-launch-host-contract.ts. + +import type { BuiltInTuiAgent, TuiAgent } from './types' +import type { AgentStartupShell } from './tui-agent-startup-shell' +import type { AgentKind } from './telemetry-events' + +/** Serializable intent kind persisted in records; the richer LaunchIntent union + * is host-only and never an RPC parameter. */ +export type AgentLaunchIntentKind = + | 'interactive' + | 'cli' + | 'automation' + | 'background' + | 'orchestration' + | 'resume' + +export type AgentLaunchNotice = + // baseAgent fills the {base} placeholder in fallback copy; label is the + // requested agent's display label. + | { code: 'missing_custom_fallback'; label: string; baseAgent: BuiltInTuiAgent } + | { code: 'disabled_custom_fallback'; label: string; baseAgent: BuiltInTuiAgent } + | { code: 'snapshot_definition_changed'; label: string } + | { code: 'env_withheld'; label: string } + | { code: 'vault_original_config_unavailable'; baseAgent: BuiltInTuiAgent } + +export type AgentLaunchNoticeCode = AgentLaunchNotice['code'] + +/** Host-owned per-terminal launch-notice state persisted with terminal/session + * metadata. The host is the sole owner: renderer/mobile stores mirror it and + * never independently recreate a dismissed notice. `launchToken` scopes + * dismissal to this terminal and is never logged or sent to telemetry. */ +export type PersistedLaunchNoticeState = { + launchToken: string + notices: readonly AgentLaunchNotice[] +} + +export type AgentLaunchFailureCode = + | 'unknown_agent' + | 'no_agent_selected' + | 'agent_definition_needs_repair' + | 'custom_agent_disabled' + | 'agent_configuration_changed' + | 'base_agent_disabled' + | 'base_agent_unavailable' + | 'missing_variable' + | 'missing_target_home' + | 'invalid_command_override' + | 'invalid_agent_args' + | 'invalid_agent_env' + | 'secure_env_transport_unavailable' + | 'launch_command_too_long' + | 'invalid_launch_snapshot' + | 'trust_preflight_failed' + | 'spawn_failed' + | 'launch_state_unknown' + | 'launch_capacity_exceeded' + +export type AgentLaunchFailureFieldHint = + | 'identity' + | 'baseAgent' + | 'label' + | 'commandOverride' + | 'args' + | 'env' + +export type AgentLaunchFailureReasonHint = + | 'unterminated_quote' + | 'quoted_line_break' + | 'cmd_metachar' + | 'control_char' + | 'empty' + | 'bounds' + | 'reserved_name' + | 'prototype_key' + | 'case_collision' + | 'duplicate_id' + | 'identity_mismatch' + | 'environment_block_too_large' + | 'arg_env_too_large' + | 'shell_operator' + | 'tilde_user' + | 'capacity' + +export type AgentLaunchFailure = { + code: AgentLaunchFailureCode + requestedAgent?: TuiAgent + baseAgent?: BuiltInTuiAgent + variable?: 'repoPath' | 'worktreePath' + // Client-safe repair hints only. Never carry values, full argv, paths, + // labels, env keys, or env values. + field?: AgentLaunchFailureFieldHint + shell?: AgentStartupShell + reason?: AgentLaunchFailureReasonHint +} + +/** Request/control-plane rejections do not describe a launch attempt and are + * never persisted over an owner's existing failure or pending state. */ +export type AgentLaunchRequestError = { + code: 'idempotency_conflict' | 'stale_agent_launch_failure' | 'untrusted_reference' +} + +export type PersistedAgentLaunchFailure = AgentLaunchFailure & { + version: 1 + failureId: string + intent: AgentLaunchIntentKind + occurredAt: number +} + +export type AgentLaunchReceipt = { + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent + notices: readonly AgentLaunchNotice[] + launchToken: string + catalogRevision: number + // Host-derived agent_started attribution: the base kind and whether a custom + // agent launched (snapshot mode === 'custom'). Client-safe (closed enum + + // boolean) — never a requested id/label. The single authority the emitters and + // the host create-emit read so client-supplied agent_kind stays vestigial. + telemetry: { agentKind: AgentKind; usedCustomAgent: boolean } +} diff --git a/src/shared/agent-launch-failure-schema.test.ts b/src/shared/agent-launch-failure-schema.test.ts new file mode 100644 index 00000000000..a7179d5f807 --- /dev/null +++ b/src/shared/agent-launch-failure-schema.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_LAUNCH_FAILURE_CODES, + agentLaunchFailureSchema, + parsePersistedAgentLaunchFailure, + persistedAgentLaunchFailureSchema +} from './agent-launch-failure-schema' +import type { PersistedAgentLaunchFailure } from './agent-launch-contract' + +function persisted( + overrides: Partial = {} +): PersistedAgentLaunchFailure { + return { + code: 'spawn_failed', + requestedAgent: 'claude', + baseAgent: 'claude', + version: 1, + failureId: 'fail-1', + intent: 'background', + occurredAt: 100, + ...overrides + } +} + +describe('persistedAgentLaunchFailureSchema', () => { + it('accepts a well-formed persisted failure and round-trips it', () => { + expect(parsePersistedAgentLaunchFailure(persisted())).toEqual(persisted()) + }) + + it('accepts every failure code in the enum (kept in sync with the union)', () => { + for (const code of AGENT_LAUNCH_FAILURE_CODES) { + expect(parsePersistedAgentLaunchFailure(persisted({ code }))).not.toBeNull() + } + }) + + it('rejects a control-plane request error masquerading as a failure', () => { + // idempotency_conflict / stale_agent_launch_failure / untrusted_reference are + // AgentLaunchRequestError codes, not failure codes — they must never parse. + expect(parsePersistedAgentLaunchFailure({ code: 'idempotency_conflict' })).toBeNull() + expect(parsePersistedAgentLaunchFailure({ code: 'stale_agent_launch_failure' })).toBeNull() + expect(parsePersistedAgentLaunchFailure({ code: 'untrusted_reference' })).toBeNull() + }) + + it('rejects an unknown/extra field rather than silently persisting it', () => { + expect(parsePersistedAgentLaunchFailure({ ...persisted(), agentArgs: '--danger' })).toBeNull() + expect( + parsePersistedAgentLaunchFailure({ ...persisted(), agentEnv: { SECRET: 'x' } }) + ).toBeNull() + }) + + it('requires version 1, a non-empty failureId, and a known intent', () => { + expect(parsePersistedAgentLaunchFailure(persisted({ version: 2 as unknown as 1 }))).toBeNull() + expect(parsePersistedAgentLaunchFailure(persisted({ failureId: '' }))).toBeNull() + expect( + parsePersistedAgentLaunchFailure( + persisted({ intent: 'mystery' as PersistedAgentLaunchFailure['intent'] }) + ) + ).toBeNull() + }) + + it('rejects an unknown requested/base agent identity', () => { + expect( + parsePersistedAgentLaunchFailure( + persisted({ + requestedAgent: 'not-an-agent' as PersistedAgentLaunchFailure['requestedAgent'] + }) + ) + ).toBeNull() + }) + + it('the unversioned failure body schema also rejects extra keys', () => { + expect(agentLaunchFailureSchema.safeParse({ code: 'spawn_failed' }).success).toBe(true) + expect(agentLaunchFailureSchema.safeParse({ code: 'spawn_failed', argv: ['x'] }).success).toBe( + false + ) + }) + + it('the versioned schema is stricter than the body schema (needs the ledger fields)', () => { + expect(persistedAgentLaunchFailureSchema.safeParse({ code: 'spawn_failed' }).success).toBe( + false + ) + }) +}) diff --git a/src/shared/agent-launch-failure-schema.ts b/src/shared/agent-launch-failure-schema.ts new file mode 100644 index 00000000000..54770efbe3e --- /dev/null +++ b/src/shared/agent-launch-failure-schema.ts @@ -0,0 +1,125 @@ +// Strict runtime validators for the persisted agent-launch failure contract. +// U6 owner records (automation run, orchestration dispatch, generic background +// attempt) round-trip these failures through JSON/SQLite, so normalization must +// fail closed: a control-plane request error must NEVER parse as a launch +// failure, and an unknown or extra field must reject rather than silently +// persist. One enum keeps every failure code, field hint, and reason hint in a +// single place so adding a union member forces updating the schema. + +import { z } from 'zod' +import { isBuiltInTuiAgent, isTuiAgent } from './tui-agent-config' +import type { + AgentLaunchFailure, + AgentLaunchFailureCode, + AgentLaunchFailureFieldHint, + AgentLaunchFailureReasonHint, + PersistedAgentLaunchFailure +} from './agent-launch-contract' +import type { AgentLaunchIntentKind } from './agent-launch-contract' +import type { BuiltInTuiAgent, TuiAgent } from './types' + +export const AGENT_LAUNCH_FAILURE_CODES = [ + 'unknown_agent', + 'no_agent_selected', + 'agent_definition_needs_repair', + 'custom_agent_disabled', + 'agent_configuration_changed', + 'base_agent_disabled', + 'base_agent_unavailable', + 'missing_variable', + 'missing_target_home', + 'invalid_command_override', + 'invalid_agent_args', + 'invalid_agent_env', + 'secure_env_transport_unavailable', + 'launch_command_too_long', + 'invalid_launch_snapshot', + 'trust_preflight_failed', + 'spawn_failed', + 'launch_state_unknown', + 'launch_capacity_exceeded' +] as const satisfies readonly AgentLaunchFailureCode[] + +export const AGENT_LAUNCH_FAILURE_FIELD_HINTS = [ + 'identity', + 'baseAgent', + 'label', + 'commandOverride', + 'args', + 'env' +] as const satisfies readonly AgentLaunchFailureFieldHint[] + +export const AGENT_LAUNCH_FAILURE_REASON_HINTS = [ + 'unterminated_quote', + 'quoted_line_break', + 'cmd_metachar', + 'control_char', + 'empty', + 'bounds', + 'reserved_name', + 'prototype_key', + 'case_collision', + 'duplicate_id', + 'identity_mismatch', + 'environment_block_too_large', + 'arg_env_too_large', + 'shell_operator', + 'tilde_user', + 'capacity' +] as const satisfies readonly AgentLaunchFailureReasonHint[] + +export const AGENT_LAUNCH_INTENT_KINDS = [ + 'interactive', + 'cli', + 'automation', + 'background', + 'orchestration', + 'resume' +] as const satisfies readonly AgentLaunchIntentKind[] + +const tuiAgentSchema = z.custom((v) => isTuiAgent(v)) +const builtInTuiAgentSchema = z.custom((v) => isBuiltInTuiAgent(v)) + +/** Strict client-safe failure body. `.strict()` rejects any extra key so a + * request error (`idempotency_conflict`, `stale_agent_launch_failure`, + * `untrusted_reference`) — whose shape is `{ code }` with a non-failure code — + * cannot masquerade as a launch failure, and a stray argv/env/path field can + * never ride through normalization. */ +export const agentLaunchFailureSchema = z + .object({ + code: z.enum(AGENT_LAUNCH_FAILURE_CODES), + requestedAgent: tuiAgentSchema.optional(), + baseAgent: builtInTuiAgentSchema.optional(), + variable: z.enum(['repoPath', 'worktreePath']).optional(), + field: z.enum(AGENT_LAUNCH_FAILURE_FIELD_HINTS).optional(), + shell: z.enum(['posix', 'powershell', 'cmd']).optional(), + reason: z.enum(AGENT_LAUNCH_FAILURE_REASON_HINTS).optional() + }) + .strict() satisfies z.ZodType + +export const persistedAgentLaunchFailureSchema = z + .object({ + code: z.enum(AGENT_LAUNCH_FAILURE_CODES), + requestedAgent: tuiAgentSchema.optional(), + baseAgent: builtInTuiAgentSchema.optional(), + variable: z.enum(['repoPath', 'worktreePath']).optional(), + field: z.enum(AGENT_LAUNCH_FAILURE_FIELD_HINTS).optional(), + shell: z.enum(['posix', 'powershell', 'cmd']).optional(), + reason: z.enum(AGENT_LAUNCH_FAILURE_REASON_HINTS).optional(), + version: z.literal(1), + failureId: z.string().min(1), + intent: z.enum(AGENT_LAUNCH_INTENT_KINDS), + occurredAt: z.number() + }) + .strict() satisfies z.ZodType + +/** Parse a stored value into a persisted launch failure, or null when it is + * malformed / carries unknown fields / is actually a request error. Owner + * records use this on read so a corrupt or forged entry drops instead of + * surfacing as a recovery card. */ +export function parsePersistedAgentLaunchFailure( + value: unknown +): PersistedAgentLaunchFailure | null { + const parsed = persistedAgentLaunchFailureSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/agent-launch-host-contract.ts b/src/shared/agent-launch-host-contract.ts new file mode 100644 index 00000000000..8c1e1e2bf84 --- /dev/null +++ b/src/shared/agent-launch-host-contract.ts @@ -0,0 +1,161 @@ +// Host-only agent-launch contracts: the resolution request, the immutable +// launch snapshot, and the complete resolved launch. This module is shared only +// so startup/persistence code can type-check; renderer, mobile, and web runtime +// code MUST NOT import it (an architecture test enforces the boundary). +// `AgentLaunchSnapshot` is a host-persistence schema that may appear in shared +// session types but is explicitly redacted from every client transport. + +import type { BuiltInTuiAgent, TuiAgent } from './types' +import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { AgentStartupShell } from './tui-agent-startup-shell' +import type { AgentPromptInjectionMode, DraftPasteReadySignal } from './tui-agent-config' +import type { StartupCommandDelivery } from './codex-startup-delivery' +import type { AgentKind } from './telemetry-events' +import type { + AgentLaunchIntentKind, + AgentLaunchFailure, + AgentLaunchNotice +} from './agent-launch-contract' + +export type AgentArgv = readonly [executable: string, ...args: string[]] + +/** Stable host-produced target scope reusing the existing ExecutionHostId + * grammar ('local' | `ssh:${host}` | `runtime:${id}`) plus the `wsl:${distro}` + * variant this feature adds — HEAD's union cannot scope a distro. The snapshot + * copy is private persistence only; never returned, logged, or used as + * authorization. */ +export type AgentLaunchExecutionHostId = + | 'local' + | `ssh:${string}` + | `runtime:${string}` + | `wsl:${string}` + +export type AgentLaunchSnapshot = Readonly<{ + version: 1 + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent + displayLabel: string + mode: 'built-in' | 'custom' | 'safe-fallback' + // Fully resolved command prefix + user argv; prompt/resume argv is excluded. + argv: AgentArgv + // Only user-configured agent env admitted by launch policy; never + // process/Orca generated env. + agentEnv: Readonly> + // Resolved env disposition at capture time. Mobile/paired replay compares this + // policy state (not env keys/values) to decide snapshot_definition_changed, so + // a value-only edit can never leak through the comparison. + capturedEnvPolicy: 'full' | 'withheld' | 'none' + target: Readonly<{ + platform: NodeJS.Platform // terminal target, never phone/browser OS + execution: 'native' | 'wsl' + shell: AgentStartupShell + isRemote: boolean + executionHostId: AgentLaunchExecutionHostId + }> +}> + +export type LaunchIntent = + | { kind: 'interactive'; client: 'desktop' | 'paired-web' | 'mobile' } + | { kind: 'cli'; command: 'worktree-create' } + | { kind: 'automation'; runId: string } + | { kind: 'background'; attemptId: string; worktreeId: string } + | { kind: 'orchestration'; taskId: string; dispatchId: string } + | { kind: 'resume'; operation: 'resume' | 'fork'; client: 'desktop' | 'paired-web' | 'mobile' } + +export type AgentReferenceAuthority = + | { + kind: 'persisted' + owner: + | 'default' + | 'quick-command' + | 'commit-message' + | 'source-control-recipe' + | 'automation' + | 'background' + | 'orchestration' + | 'workspace' + | 'session' + } + | { kind: 'live-selection' } + | { kind: 'direct' } + +export type ResolvedAgentLaunch = { + requestedAgent: TuiAgent + baseAgent: BuiltInTuiAgent + displayLabel: string + argv: AgentArgv + /** Provider resume flags appended to the command on a resume/fork replay (e.g. + * `--resume `), derived from the record's provider session — NOT part of + * the immutable snapshot argv and NOT persisted into the durable launch config, + * so it never pollutes a fresh relaunch. Absent on a non-resume launch. */ + resumeArgvSuffix?: readonly string[] + agentEnv: Readonly> + variables: { + values: { repoPath: string | null; worktreePath: string | null } + referenced: readonly ('repoPath' | 'worktreePath')[] + } + snapshot: AgentLaunchSnapshot + policy: { + intent: AgentLaunchIntentKind + mode: AgentLaunchSnapshot['mode'] + client: 'desktop' | 'paired-web' | 'mobile' | 'cli' | 'host-service' + isRemote: boolean + platform: NodeJS.Platform + promptInjectionMode: AgentPromptInjectionMode + expectedProcess: string + preflightTrust?: 'cursor' | 'copilot' | 'codex' + draftPromptFlag?: string + draftPromptEnvVar?: string + draftPasteReadySignal?: DraftPasteReadySignal + startupCommandDelivery?: StartupCommandDelivery + env: 'full' | 'withheld' | 'none' + } + notices: readonly AgentLaunchNotice[] + telemetry: { agentKind: AgentKind; usedCustomAgent: boolean } + // Host-private relevant-input guard; never persisted, returned, or logged. + admissionGuard: { + fingerprint: string + /** Config-only digest (path variables excluded) for U4's two-stage worktree + * recheck: stable between pre-create identity pinning and post-create final + * resolution, where the authoritative worktree path differs. */ + stableInputDigest: string + basis: 'explicit' | 'default' | 'snapshot' + } +} + +export type AgentLaunchResolution = + | { ok: true; launch: ResolvedAgentLaunch } + | { ok: false; failure: AgentLaunchFailure } + +export type ResolveAgentLaunchRequest = { + selection: { kind: 'agent'; agent: TuiAgent } | { kind: 'default' } + intent: LaunchIntent + reference: AgentReferenceAuthority + variables: { repoPath?: string | null; worktreePath?: string | null } + platform: NodeJS.Platform // terminal target platform + shell?: AgentStartupShell + isRemote: boolean + targetHomePath?: string | null + // Stock detection for the target's baseline PATH; configured prefixes or an + // effective user PATH override bypass this stock-name gate. null means + // detection is unavailable (unknown), which never claims "not installed". + detectedStockBaseAgents: ReadonlySet | null + executionHostId: AgentLaunchExecutionHostId + /** Host-produced: whether the authoring→terminal transport is authenticated + * AND confidential (SSH, E2EE, or trust-bound TLS). Env-bearing resolution + * across hosts fails secure_env_transport_unavailable when false; undefined + * means same-host (no transport involved). Like detection, this is an input + * to the pure resolver — never derived inside it. */ + transportConfidentialityAvailable?: boolean + persistedSnapshot?: AgentLaunchSnapshot + /** The record's provider session, supplied only on a resume/fork replay so the + * resolver appends the provider resume flags to the replayed snapshot argv. The + * session key type is implied by `baseAgent`. */ + resumeProviderSession?: AgentProviderSessionMetadata + /** Host-produced per-launch args (U7): a source-control recipe's stored + * `agentArgs`, resolved from settings by the host before resolution. Validated + * through the SAME v1 grammar/caps/secrets path as definition args and appended + * as a distinct band AFTER the definition argv and BEFORE the prompt argv. The + * client never sends this — it only threads the recipe id via `sourceRecord`. */ + perLaunchArgs?: string +} diff --git a/src/shared/agent-launch-notice-schema.test.ts b/src/shared/agent-launch-notice-schema.test.ts new file mode 100644 index 00000000000..d4fd3b64bb5 --- /dev/null +++ b/src/shared/agent-launch-notice-schema.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_LAUNCH_NOTICE_CODES, + agentLaunchNoticeCodeSchema, + persistedLaunchNoticeStateSchema +} from './agent-launch-notice-schema' + +describe('agent launch notice schema', () => { + it('accepts every enum code and rejects a non-enum code (fail closed)', () => { + for (const code of AGENT_LAUNCH_NOTICE_CODES) { + expect(agentLaunchNoticeCodeSchema.safeParse(code).success).toBe(true) + } + expect(agentLaunchNoticeCodeSchema.safeParse('not_a_code').success).toBe(false) + expect(agentLaunchNoticeCodeSchema.safeParse('').success).toBe(false) + }) + + it('validates persisted notice state and rejects a bad base agent or missing token', () => { + expect( + persistedLaunchNoticeStateSchema.safeParse({ + launchToken: 'tok', + notices: [{ code: 'disabled_custom_fallback', label: 'My Claude', baseAgent: 'claude' }] + }).success + ).toBe(true) + + // Missing launchToken fails. + expect( + persistedLaunchNoticeStateSchema.safeParse({ + notices: [{ code: 'env_withheld', label: 'x' }] + }).success + ).toBe(false) + + // Fallback notice without a valid built-in base agent fails. + expect( + persistedLaunchNoticeStateSchema.safeParse({ + launchToken: 'tok', + notices: [{ code: 'disabled_custom_fallback', label: 'x', baseAgent: 'not-an-agent' }] + }).success + ).toBe(false) + }) + + it('drops an unknown notice entry without rejecting its carrier', () => { + expect( + persistedLaunchNoticeStateSchema.parse({ + launchToken: 'tok', + notices: [ + { code: 'future_notice', secret: 'ignored' }, + { code: 'vault_original_config_unavailable', baseAgent: 'codex' } + ] + }) + ).toEqual({ + launchToken: 'tok', + notices: [{ code: 'vault_original_config_unavailable', baseAgent: 'codex' }] + }) + }) +}) diff --git a/src/shared/agent-launch-notice-schema.ts b/src/shared/agent-launch-notice-schema.ts new file mode 100644 index 00000000000..0c1435f4788 --- /dev/null +++ b/src/shared/agent-launch-notice-schema.ts @@ -0,0 +1,75 @@ +// Runtime validators for the client-safe launch-notice contract. Shared by the +// workspace-session read boundary (persisted notice state) and the dismissal +// RPC/IPC (schema-valid code check). Keeping the code enum in one place is what +// lets dismissal "fail closed" on a non-enum code. + +import { z } from 'zod' +import { isBuiltInTuiAgent } from './tui-agent-config' +import type { BuiltInTuiAgent } from './types' +import type { + AgentLaunchNotice, + AgentLaunchNoticeCode, + PersistedLaunchNoticeState +} from './agent-launch-contract' + +export const AGENT_LAUNCH_NOTICE_CODES = [ + 'missing_custom_fallback', + 'disabled_custom_fallback', + 'snapshot_definition_changed', + 'env_withheld', + 'vault_original_config_unavailable' +] as const satisfies readonly AgentLaunchNoticeCode[] + +export const agentLaunchNoticeCodeSchema = z.enum(AGENT_LAUNCH_NOTICE_CODES) + +const builtInTuiAgentSchema = z.custom((v) => isBuiltInTuiAgent(v)) + +export const agentLaunchNoticeSchema = z.discriminatedUnion('code', [ + z.object({ + code: z.literal('missing_custom_fallback'), + label: z.string(), + baseAgent: builtInTuiAgentSchema + }), + z.object({ + code: z.literal('disabled_custom_fallback'), + label: z.string(), + baseAgent: builtInTuiAgentSchema + }), + z.object({ code: z.literal('snapshot_definition_changed'), label: z.string() }), + z.object({ code: z.literal('env_withheld'), label: z.string() }), + z.object({ + code: z.literal('vault_original_config_unavailable'), + baseAgent: builtInTuiAgentSchema + }) +]) satisfies z.ZodType + +const compatibleAgentLaunchNoticeSchema = z + .unknown() + .transform((value, ctx): AgentLaunchNotice | null => { + if ( + typeof value === 'object' && + value !== null && + 'code' in value && + typeof value.code === 'string' && + !AGENT_LAUNCH_NOTICE_CODES.includes(value.code as AgentLaunchNoticeCode) + ) { + return null + } + const parsed = agentLaunchNoticeSchema.safeParse(value) + if (parsed.success) { + return parsed.data + } + ctx.addIssue({ code: 'custom', message: 'Invalid agent launch notice' }) + return z.NEVER + }) + +export const persistedLaunchNoticeStateSchema = z.object({ + launchToken: z.string().min(1), + // Why: additive notice codes must not make an older client discard the + // terminal carrier; unsupported entries are dropped independently. + notices: z + .array(compatibleAgentLaunchNoticeSchema) + .transform((notices) => + notices.filter((notice): notice is AgentLaunchNotice => notice !== null) + ) +}) satisfies z.ZodType diff --git a/src/shared/agent-launch-pending-summary.ts b/src/shared/agent-launch-pending-summary.ts new file mode 100644 index 00000000000..4741bbfea8b --- /dev/null +++ b/src/shared/agent-launch-pending-summary.ts @@ -0,0 +1,40 @@ +// Client-safe capacity-recovery sheet DTO. One redacted row per pending admitted +// launch the authenticated principal owns: source kind, base harness, target-host +// display name, admitted time, liveness, and an owner deep link when one exists. +// Secret-free by construction — it never carries a prompt, custom agent id/label, +// argv, path, launch token, or env presence/key/value, nor another principal's +// row. The host keeps the launch token private (used only for the liveness scan). + +import type { AgentLaunchIntentKind } from './agent-launch-contract' +import type { BuiltInTuiAgent } from './types' + +/** Liveness of a pending launch's owning terminal. `absent` is authoritative only + * when the host can list the owner (local terminals die with main); a possibly + * unreachable remote host reports `unknown` rather than a false `absent`. */ +export type PendingAgentLaunchLiveness = 'live' | 'absent' | 'unknown' + +/** Owner reference the client routes to the owning recovery surface. Carries only + * client-safe routable ids — never a path, prompt, agent id/label, or token. The + * host resolves each arm's routing key at summary time from its own records: run + * → owning automationId; task/session → owning worktree (no dedicated task/session + * reveal surface exists, so both route to the worktree that contains them). The + * worktreeId on task/session is optional because a producer may emit the owner id + * before its worktree scope is resolvable. */ +export type PendingAgentLaunchDeepLink = + | { kind: 'worktree'; worktreeId: string } + | { kind: 'session'; sessionId: string; worktreeId?: string } + | { kind: 'run'; runId: string; automationId: string } + | { kind: 'task'; taskId: string; worktreeId?: string } + +export type PendingAgentLaunchSummaryRow = { + sourceKind: AgentLaunchIntentKind + baseHarness: BuiltInTuiAgent + targetHostDisplayName: string + admittedAt: number + liveness: PendingAgentLaunchLiveness + deepLink?: PendingAgentLaunchDeepLink +} + +export type PendingAgentLaunchSummary = { + rows: readonly PendingAgentLaunchSummaryRow[] +} diff --git a/src/shared/agent-launch-spawn-request.ts b/src/shared/agent-launch-spawn-request.ts new file mode 100644 index 00000000000..a9fee73797c --- /dev/null +++ b/src/shared/agent-launch-spawn-request.ts @@ -0,0 +1,136 @@ +// Client-safe nested `agentLaunch` request carried by pty:spawn IPC and the +// terminal-create RPC schemas (U3). It names only the requested agent identity +// and the interactive prompt/launch policy — never a command, env, launch +// config, or resolved argv. When present, the host IGNORES any client-supplied +// command/launchConfig/launchAgent/env and resolves the launch itself through +// the host boundary. The host constructs LaunchIntent/AgentReferenceAuthority +// from its authenticated context; this shape never carries either. + +import type { TuiAgent } from './types' +import type { + AgentLaunchFailure, + AgentLaunchReceipt, + AgentLaunchRequestError +} from './agent-launch-contract' +import type { AgentSessionOwnershipKey } from './agent-session-resume' +import type { AiVaultAgent } from './ai-vault-types' +import type { ExecutionHostId } from './execution-host' + +/** Requested agent identity, or the host's stored default. A custom id is only + * admitted on THIS field, never the legacy launchAgent field. */ +export type AgentLaunchSelectionRequest = { kind: 'agent'; agent: TuiAgent } | { kind: 'default' } + +/** Names a host-verified saved owner whose stored prompt/reference authority a + * launch may use. Clients supply the owner locator only; the host validates it + * and classifies prompt authority. The full mobile/paired variant set lands + * with U7's host-owned mobile launch — Wave 1 accepts the owner locator shape + * so the wire contract is stable. */ +export type AgentLaunchSourceRecord = { + owner: + | 'default' + | 'quick-command' + | 'commit-message' + | 'source-control-recipe' + | 'session' + | 'workspace' + id?: string +} + +/** Ids-free client declaration that a spawn is an UNATTENDED background launch + * with no automation run / orchestration dispatch to own its failure (§U6, + * ledger #8/#13). The client NEVER sends a LaunchIntent or attemptId: it only + * declares the kind, and the HOST mints the attemptId, creates the generic + * background attempt before resolution, and constructs the + * LaunchIntent {kind:'background', attemptId, worktreeId} itself. A misdeclaration + * only shifts admission cap buckets; the host may reclassify. */ +export type AgentLaunchUnattendedDeclaration = { kind: 'background' } + +export type AgentLaunchSpawnRequest = { + selection: AgentLaunchSelectionRequest + /** Current interactive draft; the host applies its per-surface maximum. */ + prompt?: string + /** Launch a bare TUI when the prompt is empty (e.g. tab.newAgent). */ + allowEmptyPromptLaunch?: boolean + /** 'draft' lands the prompt UNSUBMITTED in the agent's input (native flag/env, + * or host-returned draftPrompt for post-ready paste); default 'submit'. */ + promptDelivery?: 'submit' | 'draft' + sourceRecord?: AgentLaunchSourceRecord + /** Present only for an unattended background launch; the host mints the attempt + * identity from its authenticated context. Absent = an interactive launch. */ + unattended?: AgentLaunchUnattendedDeclaration +} + +/** Provider-session resume/fork variant, distinct from AgentLaunchSpawnRequest. + * It names only the session ownership key: the host loads the private record + * (snapshot/legacy config) and resolves the launch, ignoring any client prompt, + * command, env, or launch config. `fork` copies the snapshot and appends the + * provider resume argv once; its draft rides the returned draftPrompt into the + * renderer paste writer. Unknown/ambiguous key → invalid_launch_snapshot. + * Context forks are NOT this variant — they are a plain identity-only + * AgentLaunchSpawnRequest carrying the scrollback as a 'draft' prompt. */ +export type AgentLaunchResumeRequest = { + resume: { + operation: 'resume' | 'fork' + sessionKey: AgentSessionOwnershipKey + } +} + +/** AI Vault session resume/copy variant (U5 FULL PORT). The client echoes the + * host listing's OWN discovered entry identity — it never authors locator data. + * The host re-validates that identity against a FRESH `runtime.listAiVaultSessions` + * discovery and rebuilds the resume command itself, bypassing the resolver like + * legacy opaque replay (no admission token/receipt). An entry the host's own + * fresh scan does not contain, or a field mismatch, is `invalid_launch_snapshot`. + * `operation: 'resume'` rides pty:spawn/terminal-create and spawns a normal + * terminal; `operation: 'copy'` never spawns — it is served by the host copy + * method that returns the assembled command string as a display artifact (OMP's + * only path). `filePath` is trusted desktop IPC only (OMP transcript path); every + * runtime/paired RPC surface OMITS it and the host re-derives it from its own + * fresh entry. */ +export type AgentLaunchVaultResumeEntry = { + executionHostId: ExecutionHostId + agent: AiVaultAgent + sessionId: string + resumeLocator?: string + filePath?: string +} + +export type AgentLaunchVaultResumeRequest = { + vaultResume: { + operation: 'resume' | 'copy' + entry: AgentLaunchVaultResumeEntry + } +} + +/** Host result of a 'copy' vault-resume (the non-spawning path). Carries only the + * assembled command string as a clipboard/display artifact — never argv/env/ + * token. An entry the host's own fresh discovery does not contain is an in-band + * invalid_launch_snapshot, mirroring the resume failure envelope. */ +export type AgentLaunchVaultResumeCopyResult = + | { status: 'ok'; command: string } + | { status: 'failed'; failure: { code: 'invalid_launch_snapshot' } } + +/** On-demand expanded-details disclosure of the correlated snapshot's launch + * arguments. The executable, environment, custom id, and paths stay private. */ +export type AgentLaunchVaultResumeDetailsResult = + | { status: 'ok'; args: readonly string[] } + | { status: 'unavailable' } + +/** The agentLaunch input carried by pty:spawn / terminal-create: a fresh + * selection-based launch, a provider-session resume/fork by session key, or an + * AI Vault session resume. The host discriminates on the presence of `resume` / + * `vaultResume`. */ +export type AgentLaunchInput = + | AgentLaunchSpawnRequest + | AgentLaunchResumeRequest + | AgentLaunchVaultResumeRequest + +/** Client-safe result of a host-resolved agent launch, returned alongside a + * spawn/terminal-create result. 'launched' carries only the receipt (never + * argv/env/snapshot); a pre-spawn 'failed'/'rejected' means NO PTY/terminal was + * created and — for RPC surfaces — is a successful response, not an error + * envelope, mirroring worktree.create so old-client semantics stay intact. */ +export type AgentLaunchSpawnOutcome = + | { status: 'launched'; receipt: AgentLaunchReceipt; backgroundAttemptId?: string } + | { status: 'failed'; failure: AgentLaunchFailure; backgroundAttemptId?: string } + | { status: 'rejected'; requestError: AgentLaunchRequestError } diff --git a/src/shared/agent-launch-worktree-recovery.ts b/src/shared/agent-launch-worktree-recovery.ts new file mode 100644 index 00000000000..2b59689b110 --- /dev/null +++ b/src/shared/agent-launch-worktree-recovery.ts @@ -0,0 +1,42 @@ +// Client-safe recovery contracts for a worktree's settled agent-launch failure +// (U4): the retry action a recovery card can request and the tri-state result +// arms the renderer reconciles. These cross the local IPC and runtime RPC +// boundary, so they carry only codes, receipts, and persisted failures — never +// argv/env/paths/labels. Host orchestration lives in +// src/main/agent-launch/agent-launch-worktree-{retry,forget}.ts, which re-export +// these so renderer, preload, and host all type-check against one definition. + +import type { TuiAgent } from './types' +import type { + AgentLaunchFailure, + AgentLaunchReceipt, + AgentLaunchRequestError, + PersistedAgentLaunchFailure +} from './agent-launch-contract' + +/** A recovery card can retry the pinned identity unchanged or adopt a live + * agent selection. change-agent carries no source record, so it never gains + * tombstone/safe-fallback authority (that is host-enforced on resolution). */ +export type RetryAgentLaunchAction = + | { kind: 'retry-same' } + | { kind: 'change-agent'; agent: TuiAgent } + +/** Tri-state (plus rejected) retry outcome: + * - launched: the primary agent spawned; clear the recovery card; + * - failed: a new durable attempt failure (mutation) whose failureId the card + * retries against next; + * - blocked: nothing ran and nothing changed (recovery-gate state or a + * deterministic pre-launch rejection) — keep the current card, show why; + * - rejected: benign protocol outcome (idempotency_conflict / stale) — refresh. */ +export type WorktreeRetryAgentLaunchResult = + | { status: 'launched'; receipt: AgentLaunchReceipt } + | { status: 'failed'; failure: PersistedAgentLaunchFailure } + | { status: 'blocked'; failure: AgentLaunchFailure } + | { status: 'rejected'; requestError: AgentLaunchRequestError } + +/** Forget releases Orca's local bookkeeping for a launch stranded in + * launch_state_unknown; it never kills or spawns. rejected covers the benign + * idempotency/stale protocol outcomes. */ +export type ForgetUnknownAgentLaunchResult = + | { status: 'forgotten' } + | { status: 'rejected'; requestError: AgentLaunchRequestError } diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index e0fa3568c68..5cda7e1c1ec 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -1,7 +1,7 @@ import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config' import { EXACT_NODE_ENTRYPOINT_IDENTITIES } from './agent-node-entrypoint-identities' import type { AgentType } from './agent-status-types' -import type { TuiAgent } from './tui-agent' +import type { BuiltInTuiAgent, TuiAgent } from './tui-agent' import { filterHeadlessOneShotAgentCommand } from './agent-headless-command' import { getFirstCommandToken } from './command-token-scanner' @@ -59,8 +59,8 @@ const PROCESS_TO_AGENT = new Map() const AGENT_TYPE_IDS = new Set() for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [ - TuiAgent, - (typeof TUI_AGENT_CONFIG)[TuiAgent] + BuiltInTuiAgent, + (typeof TUI_AGENT_CONFIG)[BuiltInTuiAgent] ][]) { AGENT_TYPE_IDS.add(agent) for (const candidate of [ diff --git a/src/shared/agent-reference-snapshot.ts b/src/shared/agent-reference-snapshot.ts new file mode 100644 index 00000000000..9acc7ff2c7e --- /dev/null +++ b/src/shared/agent-reference-snapshot.ts @@ -0,0 +1,123 @@ +// Versioned agent-reference DTOs: the persisted owners of agent references that +// are not catalog authoring (terminal quick commands, commit-message agent +// choice, Source Control recipe defaults). Synced as revisioned full snapshots +// in their own RPC frame, separate from the catalog snapshot, so the two +// domains never compete under the transport's 1 MiB frame cap. + +import type { CommitMessageAiSettings, TerminalQuickCommand } from './types' +import type { SourceControlAiSettings } from './source-control-ai-types' +import type { AgentProjectionStatus } from './agent-catalog-snapshot' + +export type AgentReferenceSnapshot = { + version: 1 + revision: number + terminalQuickCommands: TerminalQuickCommand[] + commitMessageAi?: CommitMessageAiSettings + sourceControlAi?: SourceControlAiSettings + // Repo-specific Source Control overrides remain in their existing repo DTO, + // but use the same owner-specific mutation/version rules. +} + +// Uncapped authoring/repair view over local preload IPC only. +export type LocalAgentReferenceSnapshot = AgentReferenceSnapshot & { + projection: AgentProjectionStatus +} + +export type AgentReferenceProjectionError = { + version: 1 + revision: number + code: 'agent_reference_payload_too_large' + maxBytes: 524_288 +} + +export type AgentReferenceMutationResult< + TSnapshot extends AgentReferenceSnapshot | LocalAgentReferenceSnapshot +> = + | { + ok: true + referenceRevision: number + catalogRevision: number // may advance when the final tombstone reference is removed + snapshot: TSnapshot + } + | { + ok: false + code: + | 'reference_revision_conflict' + | 'invalid_agent_reference' + | 'invalid_reference_field' + | 'agent_reference_payload_too_large' + referenceRevision: number + catalogRevision: number + snapshot?: TSnapshot // current snapshot on revision conflict + owner?: 'quick-command' | 'commit-message' | 'source-control-recipe' + field?: string + reason?: 'unknown_agent' | 'disabled_agent' | 'bounds' | 'conflict' + } + +/** Owner-specific v1 reference mutations. Field-level stale-reference rule, + * enforced host-side: an omitted agent field or the exact currently stored + * (possibly stale) id preserves the proven stored reference while other fields + * save; a different agent must be a currently effectively enabled live + * identity; explicit null clears it. A client can never mint persisted + * fallback authority by echoing a stale id into a different row/owner. */ +export type AgentReferenceMutation = + | { kind: 'quick-command-save'; command: TerminalQuickCommand } + | { kind: 'quick-command-delete'; id: string } + | { kind: 'quick-commands-reorder'; orderedIds: string[] } + | { kind: 'commit-message-update'; changes: Partial } + | { kind: 'source-control-update'; changes: Partial } + +export type AgentReferenceMutationRequest = { + expectedReferenceRevision: number + mutation: AgentReferenceMutation +} + +/** Serializable launch-intent kind persisted in records; the richer LaunchIntent + * union lives beside the main resolver and is never an RPC parameter. */ +export type AgentLaunchIntentKind = + | 'interactive' + | 'cli' + | 'automation' + | 'background' + | 'orchestration' + | 'resume' + +/** Persisted-owner kinds; maps one-to-one onto the tombstone reference index. */ +export type AgentReferenceOwnerKind = + | 'default' + | 'quick-command' + | 'commit-message' + | 'source-control-recipe' + | 'automation' + | 'background' + | 'orchestration' + | 'workspace' + | 'session' + +/** Per-owner reference count for delete confirmation and "Review references". + * Owner kind + count only — never prompt/config/env. Count -1 means the + * owner's store could not be read. */ +export type AgentReferenceSummary = { + owner: AgentReferenceOwnerKind + count: number +} + +/** A base-disable impact count (§973). `count` is the number of readable + * matches; `atLeast` is true when a contributing owner store could not be read, + * so the true total may be higher — the "at least N" analog of the summary's + * per-owner -1. Counts only: never a label or config. */ +export type BaseDisableImpactCount = { + count: number + atLeast: boolean +} + +/** Host-computed impact of disabling a built-in base (§973), for the confirm + * dialog. `savedReferences` counts persisted-owner references whose launch + * blocks — the base id itself plus any custom derivative of it (baseAgent === X) + * — excluding sessions, which are reported separately. `resumableSessions` + * counts resumable records on that base (covering direct and derivative launches + * alike). Enabled-derivative counts stay client-side off the catalog snapshot. */ +export type BaseDisableImpact = { + savedReferences: BaseDisableImpactCount + resumableSessions: BaseDisableImpactCount +} diff --git a/src/shared/agent-search-query.test.ts b/src/shared/agent-search-query.test.ts new file mode 100644 index 00000000000..216084e9267 --- /dev/null +++ b/src/shared/agent-search-query.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + agentSearchSummaryMatches, + buildAgentSearchSummary, + normalizeAgentSearchQuery, + truncateToUtf8ByteBudget +} from './agent-search-query' +import { AGENT_SEARCH_QUERY_MAX_BYTES, utf8ByteLength } from './custom-tui-agent-fields' + +const GRINNING = '\u{1F600}' // 😀 — 4 UTF-8 bytes, 2 UTF-16 code units + +function hasLoneSurrogate(text: string): boolean { + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index) + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) { + return true + } + index += 1 + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true + } + } + return false +} + +describe('normalizeAgentSearchQuery', () => { + it('folds NFKC, collapses White_Space, trims, and lowercases (en-US)', () => { + // Fullwidth C → NFKC C → lowercase c; whitespace run collapses; ends trim. + expect(normalizeAgentSearchQuery(' C O D E X ')).toBe('c o d e x') + expect(normalizeAgentSearchQuery('MyCodex')).toBe('mycodex') + }) + + it('caps at the 2 KiB byte budget', () => { + const result = normalizeAgentSearchQuery('a'.repeat(5000)) + expect(utf8ByteLength(result)).toBe(AGENT_SEARCH_QUERY_MAX_BYTES) + }) + + it('truncates a run of multi-byte emoji on whole code points', () => { + // 520 emoji = 2080 bytes > 2048; must land on 512 whole emoji = 2048 bytes. + const result = normalizeAgentSearchQuery(GRINNING.repeat(520)) + expect(utf8ByteLength(result)).toBe(2048) + expect([...result]).toHaveLength(512) + expect([...result].every((codePoint) => codePoint === GRINNING)).toBe(true) + expect(hasLoneSurrogate(result)).toBe(false) + }) + + it('stops at a code-point boundary rather than splitting to fill the last bytes', () => { + // 2002 ASCII (2002 bytes) leaves a 46-byte remainder under the 2048 cap; + // a 4-byte emoji packs 11× (44 bytes) and the 12th would overflow, so the + // result is 2046 bytes — proving it never splits an emoji to hit 2048 exactly. + const result = normalizeAgentSearchQuery(`${'a'.repeat(2002)}${GRINNING.repeat(20)}`) + expect(utf8ByteLength(result)).toBe(2046) + expect(result.endsWith(GRINNING)).toBe(true) + expect(hasLoneSurrogate(result)).toBe(false) + }) +}) + +describe('truncateToUtf8ByteBudget', () => { + it('returns the input unchanged when already within budget', () => { + expect(truncateToUtf8ByteBudget('codex', 2048)).toBe('codex') + }) + + it('never emits a lone surrogate at the boundary', () => { + // Budget lands one byte short of a full emoji; the emoji is dropped whole. + const result = truncateToUtf8ByteBudget(`ab${GRINNING}`, 3) + expect(result).toBe('ab') + expect(hasLoneSurrogate(result)).toBe(false) + }) +}) + +describe('buildAgentSearchSummary', () => { + it('combines label, base name, and command summary into one normalized string', () => { + const summary = buildAgentSearchSummary({ + label: 'My Codex', + baseName: 'Codex', + commandSummary: 'codex --yolo' + }) + const matches = (raw: string): boolean => + agentSearchSummaryMatches(summary, normalizeAgentSearchQuery(raw)) + expect(matches('my codex')).toBe(true) // label + expect(matches('Codex')).toBe(true) // base name, case-insensitive + expect(matches('YOLO')).toBe(true) // secondary command summary + expect(matches('nonesuch')).toBe(false) + }) + + it('omits an absent command summary without leaving a stray separator', () => { + const summary = buildAgentSearchSummary({ label: 'Plain', baseName: 'Gemini' }) + expect(summary).toBe('plain gemini') + }) + + it('bounds a corrupt oversize field to the byte budget', () => { + const summary = buildAgentSearchSummary({ label: 'x'.repeat(5000), baseName: 'codex' }) + expect(utf8ByteLength(summary)).toBeLessThanOrEqual(AGENT_SEARCH_QUERY_MAX_BYTES) + }) +}) + +describe('agentSearchSummaryMatches', () => { + it('treats an empty query as matching every row', () => { + expect(agentSearchSummaryMatches('anything', '')).toBe(true) + }) +}) diff --git a/src/shared/agent-search-query.ts b/src/shared/agent-search-query.ts new file mode 100644 index 00000000000..9a04e32ed62 --- /dev/null +++ b/src/shared/agent-search-query.ts @@ -0,0 +1,74 @@ +// Pure, node-free search normalization shared by the desktop and mobile agent +// catalog UIs. One code-point-safe byte cap for the typed query and one bounded +// per-row summary builder, so a pasted blob or a corrupt row cannot multiply +// substring-matching work across a 1,000-agent catalog. Mobile-importable: no +// Node globals, and both sides fold identically so matching cannot drift. + +import { + AGENT_SEARCH_QUERY_MAX_BYTES, + normalizeAgentLabelKey, + truncateAgentLabelForDisplay, + utf8ByteLength +} from './custom-tui-agent-fields' + +// Surrogate-safe guard applied to each raw field before NFKC folding, so an +// unbounded corrupt value cannot make normalization itself the hot path. The +// product-visible bound remains AGENT_SEARCH_QUERY_MAX_BYTES below. +const MAX_RAW_SEARCH_FIELD_CODE_UNITS = 2048 + +/** Truncate to at most `maxBytes` UTF-8 bytes at a Unicode code-point boundary — + * never mid-surrogate-pair. May return fewer than `maxBytes` when the next code + * point would overflow, rather than splitting it. */ +export function truncateToUtf8ByteBudget(text: string, maxBytes: number): string { + if (utf8ByteLength(text) <= maxBytes) { + return text + } + let usedBytes = 0 + let endCodeUnits = 0 + for (const codePoint of text) { + const codePointBytes = utf8ByteLength(codePoint) + if (usedBytes + codePointBytes > maxBytes) { + break + } + usedBytes += codePointBytes + endCodeUnits += codePoint.length + } + return text.slice(0, endCodeUnits) +} + +// Guard raw length first, then the shared label-key fold (NFKC + collapsed +// White_Space + en-US lowercase) so the query and every summary field fold the +// same way for case-insensitive substring matching. +function normalizeSearchField(raw: string): string { + return normalizeAgentLabelKey(truncateAgentLabelForDisplay(raw, MAX_RAW_SEARCH_FIELD_CODE_UNITS)) +} + +/** Normalized, code-point-safe, 2 KiB-capped search input (plan §970). */ +export function normalizeAgentSearchQuery(raw: string): string { + return truncateToUtf8ByteBudget(normalizeSearchField(raw), AGENT_SEARCH_QUERY_MAX_BYTES) +} + +export type AgentSearchSummaryFields = { + /** Row label (custom label or base display name). */ + label: string + /** Base harness canonical display name. */ + baseName: string + /** Secondary command summary shown on the row, when present. */ + commandSummary?: string +} + +/** One bounded, normalized searchable string per catalog row (label + base + * harness canonical name + secondary command summary), capped identically to + * the query so 1,000 rows — including corrupt ones — index in bounded work. */ +export function buildAgentSearchSummary(fields: AgentSearchSummaryFields): string { + const parts = [fields.label, fields.baseName, fields.commandSummary ?? ''] + .map(normalizeSearchField) + .filter((part) => part.length > 0) + return truncateToUtf8ByteBudget(parts.join(' '), AGENT_SEARCH_QUERY_MAX_BYTES) +} + +/** Case-insensitive substring match of an already-normalized query against a row + * summary. An empty query matches every row (no active filter). */ +export function agentSearchSummaryMatches(summary: string, normalizedQuery: string): boolean { + return normalizedQuery.length === 0 || summary.includes(normalizedQuery) +} diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 3e763fc20a6..f993ec7a51e 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -11,6 +11,7 @@ export const RESUMABLE_TUI_AGENTS = [ 'pi', 'mimo-code', 'droid', + 'kimi', 'grok', 'devin', 'omp', @@ -42,11 +43,39 @@ export type SleepingAgentLaunchConfig = { ompResumeFilePath?: string } +/** Provider-session ownership/resume key. `baseAgent` collapses every custom id + * sharing a base onto one owner (a custom id never opens a parallel namespace), + * and the provider-session key type ('session_id' vs 'conversation_id') is + * implied by `baseAgent`, so it is not part of the key. Used both to dedupe + * claims across preserved/queued/pending/sleeping/live and as the session key a + * resume/fork request names so the host can load the private record. */ +export type AgentSessionOwnershipKey = { + worktreeId: string + baseAgent: ResumableTuiAgent + providerSessionId: string +} + +/** Stable string form for Map/Set keying. NUL-delimited so no identifier can + * forge a boundary. */ +export function getAgentSessionOwnershipKey(key: AgentSessionOwnershipKey): string { + return `${key.worktreeId}\0${key.baseAgent}\0${key.providerSessionId}` +} + export type SleepingAgentSessionRecord = { paneKey: string tabId?: string worktreeId: string + /** Legacy identity field, read-compatible for one release; new records also + * populate `requestedAgent`/`baseAgent`. For a built-in it equals both; for a + * custom id the migration derives base into `baseAgent`. */ agent: ResumableTuiAgent + /** The originally requested identity (custom id or built-in). Optional during + * the additive migration window; the persistence migration derives it from + * `agent` for legacy records. */ + requestedAgent?: TuiAgent + /** The resumable base the requested identity resolves to; the ownership key + * and provider resume argv are keyed on this, never on `requestedAgent`. */ + baseAgent?: ResumableTuiAgent providerSession: AgentProviderSessionMetadata prompt: string state: AgentStatusState @@ -56,6 +85,9 @@ export type SleepingAgentSessionRecord = { lastAssistantMessage?: string interrupted?: boolean connectionId?: string | null + /** Legacy replay config, read-only. It transits trusted desktop IPC exactly + * once on first resume/registration, after which the host owns it as an + * opaque replay artifact; the runtime/mobile/paired session DTO omits it. */ launchConfig?: SleepingAgentLaunchConfig /** How the record was captured. Worktree-sleep records (legacy records have * no origin) are consumed by worktree activation, which opens a fresh tab. @@ -145,6 +177,16 @@ export function isResumableTuiAgent(value: unknown): value is ResumableTuiAgent return typeof value === 'string' && RESUMABLE_TUI_AGENT_SET.has(value) } +/** The provider-session key type a resumable base uses. The ownership key omits + * this (it is implied by `baseAgent`), so the host reconstructs it here when a + * resume request names only the ownership key. Antigravity keys on a + * conversation id; every other resumable base keys on a session id. */ +export function providerSessionKeyForResumableBase( + base: ResumableTuiAgent +): AgentProviderSessionKey { + return base === 'antigravity' ? 'conversation_id' : 'session_id' +} + export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessionMetadata | null { if (typeof raw !== 'object' || raw === null) { return null @@ -275,6 +317,8 @@ export function getAgentResumeArgv( return providerSession.key === 'session_id' ? ['mimo', '--session', id] : null case 'droid': return providerSession.key === 'session_id' ? ['droid', '--resume', id] : null + case 'kimi': + return providerSession.key === 'session_id' ? ['kimi', '--session', id] : null case 'grok': return providerSession.key === 'session_id' ? ['grok', '--resume', id] : null case 'devin': diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f042921fc04..ef8892e0701 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -40,6 +40,22 @@ describe('isFreshNonDoneAgentStatus', () => { }) }) +describe('AGENT_STATUS_STATES contract (U6 oracle: unchanged, no launch-failure state)', () => { + it('is exactly the four hook statuses, in order', () => { + // U6 lands NO new agent status: unattended launch failures live in owner + // records (automation run / orchestration dispatch / background attempt), + // never as a synthesized hook status. This asserts the set never grew. + expect(AGENT_STATUS_STATES).toEqual(['working', 'blocked', 'waiting', 'done']) + }) + + it('carries no launch-failure disposition', () => { + const forbidden = ['failed', 'spawn_failed', 'launch_state_unknown', 'forgotten', 'launched'] + for (const value of forbidden) { + expect(AGENT_STATUS_STATES).not.toContain(value) + } + }) +}) + describe('parseAgentStatusPayload', () => { it('parses a valid working payload', () => { const result = parseAgentStatusPayload( diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 6adc59c0396..e927734d54d 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -86,6 +86,9 @@ export type AiVaultSession = { id: string executionHostId: ExecutionHostId executionHostPlatform?: NodeJS.Platform | null + /** Host-produced fresh-scan selector. Optional only for rolling compatibility + * with older runtime hosts; new scanners always emit it. */ + resumeLocator?: string agent: AiVaultAgent sessionId: string title: string diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 24d9345242d..939139b3795 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -1,6 +1,7 @@ import type { TuiAgent } from './tui-agent' import type { SetupDecision } from './worktree/create-types' import type { TaskSourceContext, WorkspaceRunContext } from './task-source-context' +import type { AgentLaunchFailure, PersistedAgentLaunchFailure } from './agent-launch-contract' export type AutomationWorkspaceMode = 'existing' | 'new_per_run' export type AutomationExecutionTargetType = 'local' | 'ssh' @@ -156,6 +157,14 @@ export type AutomationRun = { precheckResult: AutomationPrecheckResult | null usage: AutomationRunUsage | null error: string | null + /** Why: additive structured launch failure alongside the retained generic + * `error` string. Old readers keep working off `error`; U6 readers render + * the code+hint recovery card. Absent for non-launch dispatch failures. */ + agentLaunchFailure?: PersistedAgentLaunchFailure | null + /** Why: set when an owner forgets a run stranded in `dispatching + + * launch_state_unknown`; the run moves to `dispatch_failed` and is never + * retried. Distinguishes an explicit Forget from an ordinary failure. */ + agentLaunchForgottenAt?: number | null startedAt: number | null dispatchedAt: number | null createdAt: number @@ -232,9 +241,28 @@ export type AutomationDispatchResult = { terminalPaneKey?: string | null terminalPtyId?: string | null outputSnapshot?: AutomationRunOutputSnapshot | null - precheckResult?: AutomationPrecheckResult | null usage?: AutomationRunUsage | null + precheckResult?: AutomationPrecheckResult | null error?: string | null + /** Additive structured launch failure (U6). Omit to preserve the run's + * current value; present writes/clears it. Carries a PLAIN failure over the + * wire — ledger #12: the host is the single minting authority, so the + * persisted wrapper (version/failureId/intent/occurredAt) is stamped at the + * service persist path, never by the client. The generic `error` string is + * retained independently for old readers. */ + agentLaunchFailure?: AgentLaunchFailure | null + /** Additive: set when an owner forgets a run stranded in + * dispatching + launch_state_unknown. Omit to preserve. */ + agentLaunchForgottenAt?: number | null +} + +/** The store's persist input for an automation-run update. Identical to the + * wire `AutomationDispatchResult` except the launch failure is the + * host-minted `PersistedAgentLaunchFailure`: the service stamps the plain wire + * failure (or the reconciler supplies an already-minted one) before the store + * assigns it, so the persisted run always carries the comparison-keyed id. */ +export type AutomationRunPersistInput = Omit & { + agentLaunchFailure?: PersistedAgentLaunchFailure | null } export type ExternalAutomationProvider = 'hermes' | 'openclaw' diff --git a/src/shared/background-agent-launch.test.ts b/src/shared/background-agent-launch.test.ts new file mode 100644 index 00000000000..def46096d38 --- /dev/null +++ b/src/shared/background-agent-launch.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + backgroundAgentLaunchAttemptSchema, + parseBackgroundAgentLaunchAttempt, + type BackgroundAgentLaunchAttempt, + type BackgroundAgentLaunchState +} from './background-agent-launch' +import type { PersistedAgentLaunchFailure } from './agent-launch-contract' + +const unknownFailure: PersistedAgentLaunchFailure = { + code: 'launch_state_unknown', + requestedAgent: 'custom-agent:codex:11111111-1111-4111-8111-111111111111', + baseAgent: 'codex', + version: 1, + failureId: 'fail-9', + intent: 'background', + occurredAt: 5 +} + +function attempt( + overrides: Partial = {} +): BackgroundAgentLaunchAttempt { + return { + attemptId: 'a1b2c3d4-1111-4111-8111-111111111111', + worktreeId: 'repo-a::/srv/app', + operationId: 'op-1', + requestedAgent: 'codex', + baseAgent: 'codex', + state: 'pending', + failure: null, + createdAt: 1, + updatedAt: 2, + forgottenAt: null, + ...overrides + } +} + +describe('backgroundAgentLaunchAttemptSchema', () => { + it('round-trips a well-formed attempt in each state', () => { + const states: BackgroundAgentLaunchState[] = ['pending', 'launched', 'failed', 'forgotten'] + for (const state of states) { + expect(parseBackgroundAgentLaunchAttempt(attempt({ state }))).toEqual(attempt({ state })) + } + }) + + it('models launch_state_unknown as pending coexisting with an unknown-coded failure', () => { + const stranded = attempt({ state: 'pending', failure: unknownFailure }) + const parsed = parseBackgroundAgentLaunchAttempt(stranded) + expect(parsed?.state).toBe('pending') + expect(parsed?.failure?.code).toBe('launch_state_unknown') + }) + + it('retains the failure and stamps forgottenAt on the forgotten terminal', () => { + const forgotten = attempt({ state: 'forgotten', failure: unknownFailure, forgottenAt: 42 }) + const parsed = parseBackgroundAgentLaunchAttempt(forgotten) + expect(parsed?.state).toBe('forgotten') + expect(parsed?.forgottenAt).toBe(42) + expect(parsed?.failure?.code).toBe('launch_state_unknown') + }) + + it('rejects an unknown state', () => { + expect( + parseBackgroundAgentLaunchAttempt( + attempt({ state: 'exploded' as BackgroundAgentLaunchState }) + ) + ).toBeNull() + }) + + it('rejects an unknown/extra field so a corrupt row drops on read', () => { + expect(parseBackgroundAgentLaunchAttempt({ ...attempt(), launchToken: 'leaked' })).toBeNull() + expect(parseBackgroundAgentLaunchAttempt({ ...attempt(), snapshot: {} })).toBeNull() + }) + + it('rejects an unknown requested agent and a non-null non-base baseAgent', () => { + expect( + parseBackgroundAgentLaunchAttempt( + attempt({ requestedAgent: 'nope' as BackgroundAgentLaunchAttempt['requestedAgent'] }) + ) + ).toBeNull() + expect( + parseBackgroundAgentLaunchAttempt( + attempt({ baseAgent: 'nope' as BackgroundAgentLaunchAttempt['baseAgent'] }) + ) + ).toBeNull() + }) + + it('allows a null baseAgent before resolution', () => { + expect(backgroundAgentLaunchAttemptSchema.safeParse(attempt({ baseAgent: null })).success).toBe( + true + ) + }) + + it('rejects an embedded failure that carries an unknown field', () => { + const bad = { ...attempt(), failure: { ...unknownFailure, agentEnv: { S: '1' } } } + expect(parseBackgroundAgentLaunchAttempt(bad)).toBeNull() + }) + + it('rejects an embedded control-plane request error masquerading as the failure', () => { + const bad = { + ...attempt(), + failure: { ...unknownFailure, code: 'idempotency_conflict' } + } + expect(parseBackgroundAgentLaunchAttempt(bad)).toBeNull() + }) +}) diff --git a/src/shared/background-agent-launch.ts b/src/shared/background-agent-launch.ts new file mode 100644 index 00000000000..9db22a94e37 --- /dev/null +++ b/src/shared/background-agent-launch.ts @@ -0,0 +1,108 @@ +// Client-safe contract for a GENERIC background agent-launch attempt — the +// owner record for unattended launches that have no automation run or +// orchestration dispatch to land in (GitHub work-item background launches, +// worktree-composer background terminals). Keyed by `attemptId` and pointing at +// its worktree so a background failure survives reload and its recovery card +// renders on the worktree, WITHOUT conflating with the interactive two-stage +// `WorktreeMeta.pendingAgentLaunch`/`agentLaunchFailure` (plan §U6: the generic +// attempt store is distinct from the already-created owner records). +// +// The record is created BEFORE resolution and is admission-capped; its state is +// persisted through the shared tri-state reconciler. Every field here is +// client-safe: `attemptId`/`operationId` are anti-race guards (already visible +// in client metadata, never secrets), `worktreeId` is a display/deep-link join, +// `requestedAgent` is display attribution, and `failure` is the code+hint +// contract. The private launch snapshot and token live only in the host +// operation store keyed by launch token — never in this record — so the host +// record and the client DTO are the same shape. + +import { z } from 'zod' +import { persistedAgentLaunchFailureSchema } from './agent-launch-failure-schema' +import { isBuiltInTuiAgent, isTuiAgent } from './tui-agent-config' +import type { PersistedAgentLaunchFailure } from './agent-launch-contract' +import type { RetryAgentLaunchAction } from './agent-launch-worktree-recovery' +import type { BuiltInTuiAgent, TuiAgent } from './types' + +/** Tri-state disposition a background attempt reconciles through, plus the + * owner-authorized Forget terminal. `launch_state_unknown` is NOT a separate + * state: it is modeled as `state: 'pending'` coexisting with a failure whose + * code is `launch_state_unknown` (the coexistence rule — the reservation and + * private snapshot survive until a live/absent proof or an explicit Forget), + * exactly as `WorktreeMeta.pendingAgentLaunch` + `agentLaunchFailure` model it. */ +export type BackgroundAgentLaunchState = 'pending' | 'launched' | 'failed' | 'forgotten' + +export type BackgroundAgentLaunchAttempt = { + /** Canonical lowercase UUID identifying this attempt (idempotency + deep link). */ + attemptId: string + /** Worktree this launch targets. Keeps the deep link keyed off the attempt's + * worktree, not a possibly-deleted per-run workspace. */ + worktreeId: string + /** Agent-launch operation id — the reconciler/idempotency join to the private + * operation store. Anti-race guard, not a secret. */ + operationId: string + requestedAgent: TuiAgent + /** Resolved base harness once known; null before resolution or when a request + * error prevented resolution (such attempts never enter history — see plan). */ + baseAgent: BuiltInTuiAgent | null + state: BackgroundAgentLaunchState + /** Durable code+hint failure. Present for `failed`, for a `pending` attempt + * stranded in `launch_state_unknown`, and retained through `forgotten`. */ + failure: PersistedAgentLaunchFailure | null + createdAt: number + updatedAt: number + /** Set only when an owner explicitly forgot a `launch_state_unknown` attempt. */ + forgottenAt: number | null +} + +export const backgroundAgentLaunchStateSchema = z.enum([ + 'pending', + 'launched', + 'failed', + 'forgotten' +]) satisfies z.ZodType + +/** Strict schema for the persisted/round-tripped attempt. `.strict()` rejects + * unknown fields so a corrupt or forged entry drops on read rather than + * surfacing a recovery card. */ +export const backgroundAgentLaunchAttemptSchema = z + .object({ + attemptId: z.string().min(1), + worktreeId: z.string().min(1), + operationId: z.string().min(1), + requestedAgent: z.custom((v) => isTuiAgent(v)), + baseAgent: z.custom((v) => isBuiltInTuiAgent(v)).nullable(), + state: backgroundAgentLaunchStateSchema, + failure: persistedAgentLaunchFailureSchema.nullable(), + createdAt: z.number(), + updatedAt: z.number(), + forgottenAt: z.number().nullable() + }) + .strict() satisfies z.ZodType + +/** Parse a stored attempt, or null when malformed / carrying unknown fields. + * Used on load so one corrupt row never aborts rehydrating the rest. */ +export function parseBackgroundAgentLaunchAttempt( + value: unknown +): BackgroundAgentLaunchAttempt | null { + const parsed = backgroundAgentLaunchAttemptSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +/** Owner-authorized retry of a background attempt's failure, guarded by the + * attempt id + a client mutation id (same idempotency discipline as + * `worktree.retryAgentLaunch`). Reuses the shared recovery action so the + * recovery card renders identically to the worktree and session surfaces. */ +export type RetryBackgroundAgentLaunchRequest = { + attemptId: string + expectedFailureId: string + clientMutationId: string + action: RetryAgentLaunchAction +} + +/** Owner-authorized Forget of a background attempt stranded in + * `launch_state_unknown`. Frees exactly one reservation; never kills/spawns. */ +export type ForgetBackgroundAgentLaunchRequest = { + attemptId: string + expectedOperationId: string + clientMutationId: string +} diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 6223b968b67..47ff7bfe227 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -115,7 +115,7 @@ export const DEFAULT_COMMIT_MESSAGE_AGENT_ID: TuiAgent = 'claude' export const CUSTOM_AGENT_ID = 'custom' as const export type CustomAgentId = typeof CUSTOM_AGENT_ID export type CommitMessageAgentChoice = TuiAgent | CustomAgentId -export type DefaultTuiAgentPreference = TuiAgent | 'blank' | null | undefined +export type DefaultTuiAgentPreference = TuiAgent | 'auto' | 'blank' | null | undefined export function isCustomAgentId(id: string | null | undefined): id is CustomAgentId { return id === CUSTOM_AGENT_ID @@ -135,6 +135,9 @@ export function resolveCommitMessageAgentChoice( } if ( defaultTuiAgent && + // Why: 'auto' is the migrated spelling of the legacy null Auto default; it must + // keep falling through to the commit-message default agent, not be read as an id. + defaultTuiAgent !== 'auto' && defaultTuiAgent !== 'blank' && isTuiAgentEnabled(defaultTuiAgent, disabledTuiAgents) ) { diff --git a/src/shared/custom-env-leak-scan.ts b/src/shared/custom-env-leak-scan.ts new file mode 100644 index 00000000000..3f155c4ab57 --- /dev/null +++ b/src/shared/custom-env-leak-scan.ts @@ -0,0 +1,52 @@ +// Recursively scans a client-crossing value (agent catalog projection, launch +// receipt, rendered notice copy) for configured custom-env leakage. A single +// JSON substring check can be fooled — a value split across sibling fields, or an +// env key that only ever appears as an object key — so this walks every string +// (object keys included) at any depth and reports each forbidden term it finds. +// G7 oracle-12/13 hardening: tests build the forbidden terms from a definition's +// env keys+values (deliberately distinctive so they cannot collide with legitimate +// ids/labels/args) and assert the returned array is empty. + +export type CustomEnvLeak = { term: string; at: string } + +export function scanForCustomEnvLeak( + root: unknown, + forbiddenTerms: readonly string[] +): CustomEnvLeak[] { + const terms = forbiddenTerms.filter((term) => term.length > 0) + const leaks: CustomEnvLeak[] = [] + const visit = (value: unknown, path: string): void => { + if (typeof value === 'string') { + for (const term of terms) { + if (value.includes(term)) { + leaks.push({ term, at: path }) + } + } + return + } + if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, `${path}[${index}]`)) + return + } + if (value instanceof Map) { + for (const [key, child] of value.entries()) { + visit(key, `${path}`) + visit(child, `${path}.${String(key)}`) + } + return + } + if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + // An env key can leak as an OBJECT KEY, not only inside a string value. + for (const term of terms) { + if (key.includes(term)) { + leaks.push({ term, at: `${path}.${key} (key)` }) + } + } + visit(child, `${path}.${key}`) + } + } + } + visit(root, '$') + return leaks +} diff --git a/src/shared/custom-tui-agent-fields.ts b/src/shared/custom-tui-agent-fields.ts new file mode 100644 index 00000000000..c99156c3489 --- /dev/null +++ b/src/shared/custom-tui-agent-fields.ts @@ -0,0 +1,305 @@ +// Custom TUI agent definition-field validation: bounds, labels, env, command +// override, and args template. Fail-closed and lossless — invalid input is +// rejected with a typed issue, never silently truncated or rewritten. + +import { TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' +import { validateAgentArgsTemplate } from './agent-args-tokenizer' + +export const MAX_AGENT_LABEL_CODE_UNITS = 80 +export const MAX_COMMAND_PATH_LENGTH = 4096 +export const MAX_AGENT_ARGS_CODE_UNITS = 8192 +export const MAX_CUSTOM_AGENT_ENV_ENTRIES = 64 +export const MAX_CUSTOM_AGENT_ENV_KEY_CODE_UNITS = 128 +export const MAX_CUSTOM_AGENT_ENV_VALUE_CODE_UNITS = 4096 +/** Serialized configured env bound, measured as the larger of UTF-8 bytes and + * Windows UTF-16 `key=value\0` code units including the final terminator. */ +export const MAX_CUSTOM_AGENT_ENV_BYTES = 16_384 +/** Complete UTF-8 JSON serialization bound for the local live+tombstone custom catalog. */ +export const MAX_LOCAL_AGENT_CATALOG_BYTES = 16_777_216 +/** Env-free remote projection bound per snapshot frame (transport caps frames at 1 MiB). */ +export const MAX_AGENT_CATALOG_PROJECTION_BYTES = 524_288 +/** Settings catalog search input bound (truncated at a code-point boundary before matching). */ +export const AGENT_SEARCH_QUERY_MAX_BYTES = 2048 + +export type AgentFieldIssueReason = + | 'empty' + | 'bounds' + | 'control_char' + | 'unterminated_quote' + | 'quoted_line_break' + | 'shell_operator' + | 'reserved_name' + | 'prototype_key' + | 'case_collision' + | 'env_total_bounds' + | 'duplicate_id' + | 'identity_mismatch' + +export type AgentFieldIssue = { + field: 'identity' | 'baseAgent' | 'label' | 'commandOverride' | 'args' | 'env' + reason: AgentFieldIssueReason + envEntryIndex?: number +} + +// --------------------------------------------------------------------------- +// Labels +// --------------------------------------------------------------------------- + +// Unicode White_Space, which covers more than \s adds (e.g. U+0085, U+180E excluded +// by design — it lost White_Space in Unicode 6.3; \s in JS matches the current set). +const WHITE_SPACE_RUN_RE = /\s+/gu + +/** Canonical label form: trimmed, NFKC-normalized, White_Space runs collapsed to + * one space. This is the persisted display text. */ +export function normalizeAgentLabelText(label: string): string { + return label.normalize('NFKC').replace(WHITE_SPACE_RUN_RE, ' ').trim() +} + +/** Shared collision key for label uniqueness across built-in canonical English + * names, live custom labels, and referenced tombstone labels. */ +export function normalizeAgentLabelKey(label: string): string { + return normalizeAgentLabelText(label).toLocaleLowerCase('en-US') +} + +const BUILT_IN_LABEL_KEYS: ReadonlySet = new Set( + Object.values(TUI_AGENT_DISPLAY_NAMES).map((name) => normalizeAgentLabelKey(name)) +) + +export function isBuiltInAgentLabelKey(labelKey: string): boolean { + return BUILT_IN_LABEL_KEYS.has(labelKey) +} + +export function validateAgentLabel(label: unknown): AgentFieldIssue | null { + if (typeof label !== 'string') { + return { field: 'label', reason: 'empty' } + } + const normalized = normalizeAgentLabelText(label) + if (normalized.length === 0) { + return { field: 'label', reason: 'empty' } + } + if (normalized.length > MAX_AGENT_LABEL_CODE_UNITS) { + return { field: 'label', reason: 'bounds' } + } + return null +} + +/** Surrogate-safe display truncation for locally rendering an invalid persisted + * label; never written back to the store. */ +export function truncateAgentLabelForDisplay(label: string, maxCodeUnits: number): string { + if (label.length <= maxCodeUnits) { + return label + } + let end = maxCodeUnits + const last = label.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) { + end -= 1 + } + return label.slice(0, end) +} + +// --------------------------------------------------------------------------- +// Env +// --------------------------------------------------------------------------- + +const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ +const PROTOTYPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + +// TextEncoder instead of Buffer so this module stays loadable in renderer/web +// bundles that have no Node globals. +const UTF8_ENCODER = new TextEncoder() + +export function utf8ByteLength(text: string): number { + return UTF8_ENCODER.encode(text).length +} + +// NUL, CR, and LF are rejected in env values; a code-unit scan avoids a +// control-character regex literal while covering the exact same set. +function hasEnvValueControlChar(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code === 0 || code === 10 || code === 13) { + return true + } + } + return false +} + +/** Serialized configured-env size: the larger of UTF-8 bytes and Windows UTF-16 + * `key=value\0` code units including the final block terminator. */ +export function measureCustomAgentEnvBytes(env: Readonly>): number { + let utf8 = 0 + let utf16 = 0 + for (const [key, value] of Object.entries(env)) { + const entry = `${key}=${value}` + utf8 += utf8ByteLength(entry) + 1 + utf16 += entry.length + 1 + } + // The Windows block carries one extra terminating NUL after the final entry. + utf16 += 1 + return Math.max(utf8, utf16) +} + +export function validateCustomAgentEnv(env: unknown): AgentFieldIssue[] { + if (env === null || env === undefined) { + return [] + } + if (typeof env !== 'object' || Array.isArray(env)) { + return [{ field: 'env', reason: 'bounds' }] + } + const issues: AgentFieldIssue[] = [] + const entries = Object.entries(env as Record) + if (entries.length > MAX_CUSTOM_AGENT_ENV_ENTRIES) { + issues.push({ field: 'env', reason: 'bounds' }) + } + const seenCaseInsensitive = new Map() + const validated: Record = Object.create(null) as Record + entries.forEach(([key, value], index) => { + if (PROTOTYPE_KEYS.has(key)) { + issues.push({ field: 'env', reason: 'prototype_key', envEntryIndex: index }) + return + } + if (!ENV_KEY_RE.test(key) || key.length > MAX_CUSTOM_AGENT_ENV_KEY_CODE_UNITS) { + issues.push({ field: 'env', reason: 'bounds', envEntryIndex: index }) + return + } + // Why: ORCA_* is the host attribution/control namespace; user values there + // could impersonate pane identity or hook credentials (case-insensitive to + // match Windows env semantics). + if (key.toLowerCase().startsWith('orca_')) { + issues.push({ field: 'env', reason: 'reserved_name', envEntryIndex: index }) + return + } + const lower = key.toLowerCase() + if (seenCaseInsensitive.has(lower)) { + issues.push({ field: 'env', reason: 'case_collision', envEntryIndex: index }) + return + } + seenCaseInsensitive.set(lower, index) + if (typeof value !== 'string') { + issues.push({ field: 'env', reason: 'bounds', envEntryIndex: index }) + return + } + if (value.length > MAX_CUSTOM_AGENT_ENV_VALUE_CODE_UNITS) { + issues.push({ field: 'env', reason: 'bounds', envEntryIndex: index }) + return + } + if (hasEnvValueControlChar(value)) { + issues.push({ field: 'env', reason: 'control_char', envEntryIndex: index }) + return + } + validated[key] = value + }) + if (issues.length === 0 && measureCustomAgentEnvBytes(validated) > MAX_CUSTOM_AGENT_ENV_BYTES) { + issues.push({ field: 'env', reason: 'env_total_bounds' }) + } + return issues +} + +// --------------------------------------------------------------------------- +// Command override +// --------------------------------------------------------------------------- + +// Whitespace-delimited shell operator/pipeline syntax that is almost certainly an +// attempted command list rather than a filename character: ` && `, ` || `, ` | `, +// ` ; `, ` & ` plus trailing/leading equivalents. A metacharacter embedded in a +// path segment (no surrounding whitespace) stays data. +const COMMAND_OVERRIDE_OPERATOR_RE = /(?:^|\s)(?:&&|\|\||[|;&]|[<>]{1,2})(?:\s|$)/ + +/** Decode at most one matched pair of outer quotes accepted by the editor and + * return the canonical raw value stored/persisted. */ +export function canonicalizeCommandOverride(raw: string): string { + const trimmed = raw.trim() + if (trimmed.length >= 2) { + const first = trimmed[0] + const last = trimmed.at(-1) + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + const inner = trimmed.slice(1, -1) + // Only a fully matched single outer pair decodes; embedded quotes remain data. + if (!inner.includes(first)) { + return inner + } + } + } + return trimmed +} + +export function validateCommandOverride(value: unknown): AgentFieldIssue | null { + if (value === undefined || value === null) { + return null + } + if (typeof value !== 'string') { + return { field: 'commandOverride', reason: 'empty' } + } + const canonical = canonicalizeCommandOverride(value) + if (canonical.length === 0) { + return { field: 'commandOverride', reason: 'empty' } + } + if (canonical.length > MAX_COMMAND_PATH_LENGTH) { + return { field: 'commandOverride', reason: 'bounds' } + } + // eslint-disable-next-line no-control-regex -- rejecting control chars is the point + if (/[\0\r\n\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(canonical)) { + return { field: 'commandOverride', reason: 'control_char' } + } + // Unbalanced legacy outer quoting: starts or ends with a quote that did not + // decode as a matched pair above. + const first = canonical[0] + const last = canonical.at(-1) + if (first === '"' || first === "'" || last === '"' || last === "'") { + return { field: 'commandOverride', reason: 'unterminated_quote' } + } + if (COMMAND_OVERRIDE_OPERATOR_RE.test(canonical)) { + // Rejected as likely accidental shell syntax for repairability, not because + // the structured argv boundary could execute it. + return { field: 'commandOverride', reason: 'shell_operator' } + } + return null +} + +/** Built-in command overrides keep multi-token wrapper compatibility, so only + * hard bounds and control characters are save-rejected (operator tokens fail at + * launch, repairably) — never the one-executable quote/operator rules above. The + * raw value is preserved, not canonicalized to a single argv element. */ +export function validateBuiltInCommandOverride(value: string | null): AgentFieldIssue | null { + if (value === null) { + return null + } + if (value.length > MAX_COMMAND_PATH_LENGTH) { + return { field: 'commandOverride', reason: 'bounds' } + } + // eslint-disable-next-line no-control-regex -- rejecting control chars is the point + if (/[\0\r\n\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) { + return { field: 'commandOverride', reason: 'control_char' } + } + return null +} + +// --------------------------------------------------------------------------- +// Args template +// --------------------------------------------------------------------------- + +export function validateAgentArgs(value: unknown): AgentFieldIssue | null { + if (value === undefined || value === null) { + return null + } + if (typeof value !== 'string') { + return { field: 'args', reason: 'bounds' } + } + if (value.length > MAX_AGENT_ARGS_CODE_UNITS) { + return { field: 'args', reason: 'bounds' } + } + const result = validateAgentArgsTemplate(value) + if (!result.ok) { + return { field: 'args', reason: result.reason } + } + return null +} + +/** Built-in args are legacy shell text tokenized per target shell at launch, not + * the v1 custom grammar, so only the length bound is save-rejected here. */ +export function validateBuiltInArgs(value: string): AgentFieldIssue | null { + if (value.length > MAX_AGENT_ARGS_CODE_UNITS) { + return { field: 'args', reason: 'bounds' } + } + return null +} diff --git a/src/shared/custom-tui-agent-identity.ts b/src/shared/custom-tui-agent-identity.ts new file mode 100644 index 00000000000..d5cfb3ab9e9 --- /dev/null +++ b/src/shared/custom-tui-agent-identity.ts @@ -0,0 +1,40 @@ +// Custom TUI agent id syntax: prefix, guards, decomposition, and minting. Syntax +// alone never grants launch/base authority — existence must be proven against the +// live catalog and tombstones. + +import type { BuiltInTuiAgent, CustomTuiAgentId } from './types' +import { isBuiltInTuiAgent, isWellFormedCustomTuiAgentId } from './tui-agent-config' + +export const CUSTOM_TUI_AGENT_ID_PREFIX = 'custom-agent:' + +export function isCustomTuiAgentId(value: unknown): value is CustomTuiAgentId { + return isWellFormedCustomTuiAgentId(value) +} + +/** Syntax-only decomposition. Existence and launch/fallback authority must be + * proven against the live catalog/tombstones; the encoded base alone never + * grants a base harness. */ +export function parseCustomTuiAgentId( + value: unknown +): { baseAgent: BuiltInTuiAgent; suffix: string } | null { + if (typeof value !== 'string' || !value.startsWith(CUSTOM_TUI_AGENT_ID_PREFIX)) { + return null + } + const rest = value.slice(CUSTOM_TUI_AGENT_ID_PREFIX.length) + const lastColon = rest.lastIndexOf(':') + if (lastColon <= 0) { + return null + } + const base = rest.slice(0, lastColon) + const suffix = rest.slice(lastColon + 1) + if (!isBuiltInTuiAgent(base) || !isWellFormedCustomTuiAgentId(value)) { + return null + } + return { baseAgent: base, suffix } +} + +/** Mint a new canonical id. Main mints ids only after full draft validation; + * create/duplicate RPCs never accept a client-supplied id. */ +export function mintCustomTuiAgentId(baseAgent: BuiltInTuiAgent): CustomTuiAgentId { + return `${CUSTOM_TUI_AGENT_ID_PREFIX}${baseAgent}:${crypto.randomUUID()}` +} diff --git a/src/shared/custom-tui-agents.test.ts b/src/shared/custom-tui-agents.test.ts new file mode 100644 index 00000000000..1dcabb8b754 --- /dev/null +++ b/src/shared/custom-tui-agents.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgent, CustomTuiAgentId, DeletedCustomTuiAgent } from './types' +import { + canonicalizeCommandOverride, + getAgentIdentity, + isCustomTuiAgentId, + measureCustomAgentEnvBytes, + mintCustomTuiAgentId, + MAX_CUSTOM_AGENT_ENV_BYTES, + normalizeAgentCatalog, + normalizeAgentLabelKey, + parseCustomTuiAgentId, + resolveTuiAgentBaseAgent, + resolveTuiAgentConfig, + truncateAgentLabelForDisplay, + validateAgentLabel, + validateBuiltInArgs, + validateBuiltInCommandOverride, + validateCommandOverride, + validateCustomAgentEnv +} from './custom-tui-agents' +import { TUI_AGENT_CONFIG } from './tui-agent-config' + +const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' +const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' + +function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +function liveAgent(overrides: Partial = {}): CustomTuiAgent { + return { + id: customId('codex'), + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +describe('custom agent id grammar', () => { + it('accepts only canonical lowercase UUID suffixes with a known encoded base', () => { + expect(isCustomTuiAgentId(customId('codex'))).toBe(true) + expect(isCustomTuiAgentId(customId('claude-agent-teams'))).toBe(true) + expect(isCustomTuiAgentId(`custom-agent:codex:${UUID_A.toUpperCase()}`)).toBe(false) + expect(isCustomTuiAgentId('custom-agent:codex:not-a-uuid')).toBe(false) + expect(isCustomTuiAgentId(`custom-agent:not-a-base:${UUID_A}`)).toBe(false) + expect(isCustomTuiAgentId(`agent-profile:codex:${UUID_A}`)).toBe(false) + expect(isCustomTuiAgentId('codex')).toBe(false) + expect(isCustomTuiAgentId(42)).toBe(false) + }) + + it('parses the encoded base without granting authority', () => { + expect(parseCustomTuiAgentId(customId('claude'))).toEqual({ + baseAgent: 'claude', + suffix: UUID_A + }) + expect(parseCustomTuiAgentId(`custom-agent:nope:${UUID_A}`)).toBeNull() + }) + + it('mints canonical ids', () => { + const id = mintCustomTuiAgentId('codex') + expect(isCustomTuiAgentId(id)).toBe(true) + expect(parseCustomTuiAgentId(id)?.baseAgent).toBe('codex') + }) +}) + +describe('label normalization', () => { + it('trims, NFKC-normalizes, collapses whitespace runs, and case-folds the key', () => { + expect(normalizeAgentLabelKey(' My Agent ')).toBe('my agent') + // NFKC: fullwidth letters normalize to ASCII. + expect(normalizeAgentLabelKey('Codex')).toBe('codex') + // Non-space Unicode whitespace collapses too. + expect(normalizeAgentLabelKey('a b')).toBe('a b') + }) + + it('bounds labels at 80 UTF-16 code units after normalization', () => { + expect(validateAgentLabel('x'.repeat(80))).toBeNull() + expect(validateAgentLabel('x'.repeat(81))).toEqual({ field: 'label', reason: 'bounds' }) + expect(validateAgentLabel('')).toEqual({ field: 'label', reason: 'empty' }) + expect(validateAgentLabel(' ')).toEqual({ field: 'label', reason: 'empty' }) + }) + + it('truncates surrogate-safely for display only', () => { + const label = 'ab💩' + expect(truncateAgentLabelForDisplay(label, 3)).toBe('ab') + expect(truncateAgentLabelForDisplay(label, 4)).toBe('ab💩') + }) +}) + +describe('env validation', () => { + it('accepts a valid map', () => { + expect(validateCustomAgentEnv({ FOO: 'bar', A_1: 'x y = z' })).toEqual([]) + }) + + it('rejects prototype-polluting, reserved, malformed, and case-colliding keys', () => { + expect(validateCustomAgentEnv({ __proto__: 'x' })).toEqual([]) + // Object literal __proto__ does not create an own property; use a crafted object. + const proto = JSON.parse('{"__proto__": "x"}') as Record + expect(validateCustomAgentEnv(proto)).toEqual([ + { field: 'env', reason: 'prototype_key', envEntryIndex: 0 } + ]) + expect(validateCustomAgentEnv({ ORCA_PANE_KEY: 'x' })).toEqual([ + { field: 'env', reason: 'reserved_name', envEntryIndex: 0 } + ]) + expect(validateCustomAgentEnv({ orca_thing: 'x' })).toEqual([ + { field: 'env', reason: 'reserved_name', envEntryIndex: 0 } + ]) + expect(validateCustomAgentEnv({ '1BAD': 'x' })).toEqual([ + { field: 'env', reason: 'bounds', envEntryIndex: 0 } + ]) + expect(validateCustomAgentEnv({ 'BAD-NAME': 'x' })).toEqual([ + { field: 'env', reason: 'bounds', envEntryIndex: 0 } + ]) + expect(validateCustomAgentEnv({ Path: 'a', PATH: 'b' })).toEqual([ + { field: 'env', reason: 'case_collision', envEntryIndex: 1 } + ]) + }) + + it('rejects NUL and newline in values and over-bound sizes', () => { + expect(validateCustomAgentEnv({ FOO: 'a\nb' })).toEqual([ + { field: 'env', reason: 'control_char', envEntryIndex: 0 } + ]) + expect(validateCustomAgentEnv({ FOO: 'a\0b' })).toEqual([ + { field: 'env', reason: 'control_char', envEntryIndex: 0 } + ]) + expect(validateCustomAgentEnv({ FOO: 'x'.repeat(4097) })).toEqual([ + { field: 'env', reason: 'bounds', envEntryIndex: 0 } + ]) + const tooMany: Record = {} + for (let i = 0; i < 65; i += 1) { + tooMany[`KEY_${i}`] = 'v' + } + expect(validateCustomAgentEnv(tooMany)).toContainEqual({ field: 'env', reason: 'bounds' }) + }) + + it('enforces the 16 KiB aggregate bound as the larger of UTF-8 and UTF-16 measures', () => { + // 4 entries x ~4096-unit values exceed 16384 in both measures. + const env: Record = {} + for (let i = 0; i < 4; i += 1) { + env[`K${i}`] = 'v'.repeat(4096) + } + expect(validateCustomAgentEnv(env)).toEqual([{ field: 'env', reason: 'env_total_bounds' }]) + // Multi-byte UTF-8: the UTF-8 measure trips the cap even when UTF-16 units fit. + const multiByte: Record = {} + for (let i = 0; i < 5; i += 1) { + multiByte[`M${i}`] = '€'.repeat(1200) + } + expect(measureCustomAgentEnvBytes(multiByte)).toBeGreaterThan(MAX_CUSTOM_AGENT_ENV_BYTES) + expect(validateCustomAgentEnv(multiByte)).toEqual([ + { field: 'env', reason: 'env_total_bounds' } + ]) + }) +}) + +describe('command override validation', () => { + it('accepts paths with spaces and ordinary metacharacters as one argv element', () => { + expect(validateCommandOverride('/usr/local/bin/codex')).toBeNull() + expect(validateCommandOverride('C:\\Program Files\\Codex\\codex.exe')).toBeNull() + expect(validateCommandOverride('/opt/tools (beta)/codex%20/run')).toBeNull() + expect(validateCommandOverride('/opt/a&b/codex')).toBeNull() + expect(validateCommandOverride(undefined)).toBeNull() + }) + + it('decodes one matched pair of outer quotes', () => { + expect(canonicalizeCommandOverride('"/path with spaces/codex"')).toBe('/path with spaces/codex') + expect(canonicalizeCommandOverride("'/path/codex'")).toBe('/path/codex') + expect(canonicalizeCommandOverride('/plain/codex')).toBe('/plain/codex') + // Embedded same-quote characters keep the raw value (no second decode). + expect(canonicalizeCommandOverride('"a"b"')).toBe('"a"b"') + }) + + it('accepts a multi-token built-in wrapper that the one-executable rule rejects', () => { + // Built-in overrides keep wrapper compatibility; only control chars and bounds fail. + expect(validateBuiltInCommandOverride('mise exec -- codex')).toBeNull() + expect(validateBuiltInCommandOverride('codex && rm -rf /')).toBeNull() + expect(validateBuiltInCommandOverride(null)).toBeNull() + expect(validateBuiltInCommandOverride('a\nb')).toEqual({ + field: 'commandOverride', + reason: 'control_char' + }) + expect(validateBuiltInCommandOverride('x'.repeat(4097))).toEqual({ + field: 'commandOverride', + reason: 'bounds' + }) + }) + + it('bounds built-in args by length only, without the v1 grammar', () => { + expect(validateBuiltInArgs('--model "gpt')).toBeNull() + expect(validateBuiltInArgs('x'.repeat(8193))).toEqual({ field: 'args', reason: 'bounds' }) + }) + + it('rejects control characters, unbalanced quoting, operators, and bounds', () => { + expect(validateCommandOverride('a\nb')).toEqual({ + field: 'commandOverride', + reason: 'control_char' + }) + expect(validateCommandOverride('"unclosed')).toEqual({ + field: 'commandOverride', + reason: 'unterminated_quote' + }) + expect(validateCommandOverride('codex && rm -rf /')).toEqual({ + field: 'commandOverride', + reason: 'shell_operator' + }) + expect(validateCommandOverride('codex | tee log')).toEqual({ + field: 'commandOverride', + reason: 'shell_operator' + }) + expect(validateCommandOverride('a > b')).toEqual({ + field: 'commandOverride', + reason: 'shell_operator' + }) + expect(validateCommandOverride('x'.repeat(4097))).toEqual({ + field: 'commandOverride', + reason: 'bounds' + }) + expect(validateCommandOverride(' ')).toEqual({ field: 'commandOverride', reason: 'empty' }) + }) +}) + +describe('normalizeAgentCatalog', () => { + it('indexes valid live agents in creation order and preserves index order', () => { + const first = liveAgent({ id: customId('codex', UUID_A), label: 'First' }) + const second = liveAgent({ + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Second' + }) + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [first, second], + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + expect(catalog.liveCustomAgents.map((agent) => agent.label)).toEqual(['First', 'Second']) + expect(catalog.liveById.get(first.id)?.label).toBe('First') + expect(catalog.corruptRows).toEqual([]) + expect(catalog.defaultAgent).toBe('auto') + }) + + it('lets a same-id tombstone win over a live row', () => { + const id = customId('codex') + const tombstone: DeletedCustomTuiAgent = { + id, + baseAgent: 'codex', + label: 'Old', + deletedAt: 123 + } + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [liveAgent({ id })], + deletedCustomTuiAgents: [tombstone], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + expect(catalog.liveById.has(id)).toBe(false) + expect(catalog.tombstonesById.get(id)?.label).toBe('Old') + }) + + it('marks base/id mismatch as corrupt, never rewriting either side', () => { + const row = liveAgent({ + id: customId('codex'), + baseAgent: 'claude' as CustomTuiAgent['baseAgent'] + }) + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [row], + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + expect(catalog.liveById.size).toBe(0) + expect(catalog.corruptRows).toHaveLength(1) + expect(catalog.corruptRows[0].issues).toContainEqual({ + field: 'identity', + reason: 'identity_mismatch' + }) + }) + + it('quarantines duplicate live ids as a group', () => { + const id = customId('codex') + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [liveAgent({ id, label: 'One' }), liveAgent({ id, label: 'Two' })], + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + expect(catalog.liveById.size).toBe(0) + expect(catalog.corruptRows).toHaveLength(2) + for (const row of catalog.corruptRows) { + expect(row.issues).toContainEqual({ field: 'identity', reason: 'duplicate_id' }) + } + }) + + it('keeps repair-required rows addressable but not live', () => { + const row = liveAgent({ label: '' }) + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [row], + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + expect(catalog.liveById.size).toBe(0) + expect(catalog.repairRequiredById.get(row.id)?.issues).toContainEqual({ + field: 'label', + reason: 'empty' + }) + }) + + it('normalizes missing syncEnv to false and canonicalizes the stored label', () => { + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [{ ...liveAgent({ label: ' My Agent ' }), syncEnv: undefined }], + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + const stored = catalog.liveCustomAgents[0] + expect(stored.syncEnv).toBe(false) + expect(stored.label).toBe('My Agent') + }) + + it('keeps only known built-ins or live/repair custom ids in the disabled set', () => { + const live = liveAgent() + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [live], + deletedCustomTuiAgents: [], + disabledTuiAgents: ['codex', live.id, customId('claude', UUID_B), 'garbage'], + defaultTuiAgent: 'auto' + }) + expect(catalog.disabledAgents.has('codex')).toBe(true) + expect(catalog.disabledAgents.has(live.id)).toBe(true) + expect(catalog.disabledAgents.has(customId('claude', UUID_B))).toBe(false) + }) + + describe('default reference validation', () => { + it('keeps a live disabled or tombstoned custom default as a stored reference', () => { + const live = liveAgent() + const disabledResult = normalizeAgentCatalog({ + customTuiAgents: [live], + deletedCustomTuiAgents: [], + disabledTuiAgents: [live.id], + defaultTuiAgent: live.id + }) + expect(disabledResult.catalog.defaultAgent).toBe(live.id) + expect(disabledResult.defaultRepairedToNull).toBe(false) + + const tombstoned = normalizeAgentCatalog({ + customTuiAgents: [], + deletedCustomTuiAgents: [ + { id: live.id, baseAgent: 'codex', label: 'My Codex', deletedAt: 1 } + ], + disabledTuiAgents: [], + defaultTuiAgent: live.id + }) + expect(tombstoned.catalog.defaultAgent).toBe(live.id) + }) + + it('repairs an unknown custom default to null (id syntax grants nothing)', () => { + const result = normalizeAgentCatalog({ + customTuiAgents: [], + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: customId('codex', UUID_B) + }) + expect(result.catalog.defaultAgent).toBeNull() + expect(result.defaultRepairedToNull).toBe(true) + }) + + it('repairs a default whose base is disabled to null', () => { + const live = liveAgent() + const derivative = normalizeAgentCatalog({ + customTuiAgents: [live], + deletedCustomTuiAgents: [], + disabledTuiAgents: ['codex'], + defaultTuiAgent: live.id + }) + expect(derivative.catalog.defaultAgent).toBeNull() + expect(derivative.defaultRepairedToNull).toBe(true) + + const builtIn = normalizeAgentCatalog({ + customTuiAgents: [], + deletedCustomTuiAgents: [], + disabledTuiAgents: ['codex'], + defaultTuiAgent: 'codex' + }) + expect(builtIn.catalog.defaultAgent).toBeNull() + expect(builtIn.defaultRepairedToNull).toBe(true) + }) + + it('never converts blank, auto, and null into one another', () => { + for (const value of ['auto', 'blank', null] as const) { + const result = normalizeAgentCatalog({ + customTuiAgents: [], + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: value + }) + expect(result.catalog.defaultAgent).toBe(value) + expect(result.defaultRepairedToNull).toBe(false) + } + }) + }) + + it('ignores malformed tombstones as launch authority while never inventing one', () => { + const badTombstone = { + id: 'custom-agent:codex:not-a-uuid', + baseAgent: 'codex', + label: 'Bad', + deletedAt: 1 + } + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [], + deletedCustomTuiAgents: [badTombstone], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + expect(catalog.tombstonesById.size).toBe(0) + }) +}) + +describe('getAgentIdentity', () => { + const live = liveAgent() + const tombstoneId = customId('claude', UUID_B) + const { catalog } = normalizeAgentCatalog({ + customTuiAgents: [live], + deletedCustomTuiAgents: [{ id: tombstoneId, baseAgent: 'claude', label: 'Gone', deletedAt: 1 }], + disabledTuiAgents: [], + defaultTuiAgent: 'auto' + }) + + it('resolves built-ins, live customs, and tombstones distinctly', () => { + expect(getAgentIdentity('codex', catalog)).toEqual({ + kind: 'built-in', + requestedAgent: 'codex', + baseAgent: 'codex' + }) + expect(getAgentIdentity(live.id, catalog)).toMatchObject({ + kind: 'custom', + baseAgent: 'codex' + }) + expect(getAgentIdentity(tombstoneId, catalog)).toMatchObject({ + kind: 'deleted', + baseAgent: 'claude' + }) + }) + + it('returns null for well-formed but unknown ids', () => { + expect(getAgentIdentity(customId('codex', UUID_B), catalog)).toBeNull() + }) +}) + +describe('resolveTuiAgentBaseAgent', () => { + const live = liveAgent() + it('returns proven bases only', () => { + expect(resolveTuiAgentBaseAgent('claude')).toBe('claude') + expect(resolveTuiAgentBaseAgent(live.id, [live])).toBe('codex') + expect( + resolveTuiAgentBaseAgent( + customId('claude', UUID_B), + [live], + [{ id: customId('claude', UUID_B), baseAgent: 'claude', label: 'Gone', deletedAt: 1 }] + ) + ).toBe('claude') + expect(resolveTuiAgentBaseAgent(customId('codex', UUID_B), [live])).toBeNull() + expect(resolveTuiAgentBaseAgent(null)).toBeNull() + }) +}) + +describe('resolveTuiAgentConfig (base accessor, oracle 16)', () => { + const live = liveAgent() // baseAgent: 'codex' + const tombstone: DeletedCustomTuiAgent = { + id: customId('claude', UUID_B), + baseAgent: 'claude', + label: 'Gone', + deletedAt: 1 + } + + it('returns the base config for a built-in id (identity)', () => { + expect(resolveTuiAgentConfig('claude')).toBe(TUI_AGENT_CONFIG.claude) + }) + + it('resolves a live custom id to its base config, never undefined', () => { + const config = resolveTuiAgentConfig(live.id, [live]) + expect(config).toBe(TUI_AGENT_CONFIG.codex) + // The oracle-16 hazard: a raw TUI_AGENT_CONFIG[customId] would be undefined. + expect(config).not.toBeUndefined() + expect(config?.promptInjectionMode).toBe(TUI_AGENT_CONFIG.codex.promptInjectionMode) + }) + + it('resolves a tombstoned custom id to its base config', () => { + expect(resolveTuiAgentConfig(tombstone.id, [], [tombstone])).toBe(TUI_AGENT_CONFIG.claude) + }) + + it('returns null for an unresolvable or empty id (never a static-map read)', () => { + expect(resolveTuiAgentConfig(customId('codex', UUID_B), [live])).toBeNull() + expect(resolveTuiAgentConfig(null)).toBeNull() + expect(resolveTuiAgentConfig(undefined)).toBeNull() + }) +}) diff --git a/src/shared/custom-tui-agents.ts b/src/shared/custom-tui-agents.ts new file mode 100644 index 00000000000..a9d6fef96dc --- /dev/null +++ b/src/shared/custom-tui-agents.ts @@ -0,0 +1,117 @@ +// Custom TUI agent identity accessors over the normalized catalog. This module is +// the single authority for what a well-formed custom agent is; static behavior +// registries stay keyed by BuiltInTuiAgent and dynamic ids resolve through +// `getAgentIdentity` before any registry lookup. Identity, field validation, and +// catalog normalization live in the re-exported sibling modules. + +import type { + BuiltInTuiAgent, + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + TuiAgent +} from './types' +import { isBuiltInTuiAgent, TUI_AGENT_CONFIG, type TuiAgentConfig } from './tui-agent-config' +import { isCustomTuiAgentId } from './custom-tui-agent-identity' +import type { AgentCatalog } from './agent-catalog-normalization' + +export * from './custom-tui-agent-identity' +export * from './custom-tui-agent-fields' +export * from './agent-catalog-normalization' + +export type AgentIdentity = + | { kind: 'built-in'; requestedAgent: BuiltInTuiAgent; baseAgent: BuiltInTuiAgent } + | { + kind: 'custom' + requestedAgent: CustomTuiAgentId + baseAgent: BuiltInTuiAgent + definition: CustomTuiAgent + } + | { + kind: 'deleted' + requestedAgent: CustomTuiAgentId + baseAgent: BuiltInTuiAgent + tombstone: DeletedCustomTuiAgent + } + +/** The one dynamic accessor used outside the resolver. Returns null for unknown + * ids (including well-formed custom ids with neither definition nor tombstone). */ +export function getAgentIdentity(agent: TuiAgent, catalog: AgentCatalog): AgentIdentity | null { + if (isBuiltInTuiAgent(agent)) { + return { kind: 'built-in', requestedAgent: agent, baseAgent: agent } + } + if (!isCustomTuiAgentId(agent)) { + return null + } + const definition = catalog.liveById.get(agent) + if (definition) { + return { + kind: 'custom', + requestedAgent: agent, + baseAgent: definition.baseAgent, + definition + } + } + const tombstone = catalog.tombstonesById.get(agent) + if (tombstone) { + return { + kind: 'deleted', + requestedAgent: agent, + baseAgent: tombstone.baseAgent, + tombstone + } + } + return null +} + +/** Catalog-validated base lookup for surfaces that only carry raw settings arrays + * (renderer store, mobile parity tables). Returns the built-in itself, the proven + * base of a live/repair/tombstoned custom id, or null for unknown ids — never a + * base derived from id syntax alone. */ +export function resolveTuiAgentBaseAgent( + agent: TuiAgent | null | undefined, + customTuiAgents?: readonly CustomTuiAgent[] | null, + deletedCustomTuiAgents?: readonly DeletedCustomTuiAgent[] | null +): BuiltInTuiAgent | null { + if (!agent) { + return null + } + if (isBuiltInTuiAgent(agent)) { + return agent + } + if (!isCustomTuiAgentId(agent)) { + return null + } + const live = customTuiAgents?.find((candidate) => candidate?.id === agent) + if (live && isBuiltInTuiAgent(live.baseAgent)) { + return live.baseAgent + } + const tombstone = deletedCustomTuiAgents?.find((candidate) => candidate?.id === agent) + if (tombstone && isBuiltInTuiAgent(tombstone.baseAgent)) { + return tombstone.baseAgent + } + return null +} + +// Legacy startup paths must resolve custom ids through the catalog first; a +// custom id reaching here is a programming error, never a launchable state. +export function requireBuiltInTuiAgentConfig(agent: TuiAgent): TuiAgentConfig { + if (!isBuiltInTuiAgent(agent)) { + throw new Error('legacy agent startup path requires a built-in agent id') + } + return TUI_AGENT_CONFIG[agent] +} + +/** Base accessor for the built-in-only static config (oracle 16): resolve an + * agent id — built-in OR custom — to its base harness's TuiAgentConfig. A custom + * agent reads its base's config; an unresolvable or tombstoned-without-base id + * returns null. Callers must never index TUI_AGENT_CONFIG with a raw TuiAgent, + * because a custom id would silently yield undefined (noImplicitAny hides it). */ +export function resolveTuiAgentConfig( + agent: TuiAgent | null | undefined, + customTuiAgents?: readonly CustomTuiAgent[] | null, + deletedCustomTuiAgents?: readonly DeletedCustomTuiAgent[] | null +): TuiAgentConfig | null { + const base = resolveTuiAgentBaseAgent(agent, customTuiAgents, deletedCustomTuiAgents) + return base ? TUI_AGENT_CONFIG[base] : null +} diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 5b746515548..d56e2e661c4 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -25,6 +25,7 @@ import type { CtrlTabOrderMode } from './tab-types' import type { TerminalColorOverrides } from './terminal-color-overrides' import type { TerminalQuickCommand } from './terminal-quick-command-types' import type { TuiAgent } from './tui-agent' +import type { CustomTuiAgent, DeletedCustomTuiAgent } from './types' import type { AgentDashboardMode, BranchPrefixStrategy, @@ -309,9 +310,14 @@ export type GlobalSettings = { * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) * - TuiAgent: a specific agent id */ - defaultTuiAgent: TuiAgent | 'blank' | null + defaultTuiAgent: TuiAgent | 'auto' | 'blank' | null /** Agents hidden from picker/auto-launch; detection stays a raw PATH snapshot. */ disabledTuiAgents: TuiAgent[] + customTuiAgents?: CustomTuiAgent[] + deletedCustomTuiAgents?: DeletedCustomTuiAgent[] + agentCatalogSchemaVersion?: number + agentCatalogRevision?: number + agentReferenceRevision?: number /** Master switch for the experimental plugin system. Off by default: no * discovery, no panels, no plugin code paths run at all. */ pluginSystemEnabled: boolean diff --git a/src/shared/hermes-startup-query.ts b/src/shared/hermes-startup-query.ts index a8536f91601..6d5581f75c7 100644 --- a/src/shared/hermes-startup-query.ts +++ b/src/shared/hermes-startup-query.ts @@ -184,6 +184,37 @@ export function planHermesStartupQuery(args: { if (!argv) { return null } + return finalizeQueryPlan(argv, args) +} + +/** Query plan for a host-resolved launch whose argv is already structured — + * no shell-text re-tokenization; the resolver's argv is normalized directly. */ +export function planHermesStartupQueryFromArgv(args: { + argv: readonly string[] + prompt: string + agentEnv?: Record | null + platform: NodeJS.Platform + shell: AgentStartupShell +}): { command: string; env: Record } | null { + if (args.argv.length === 0) { + return null + } + const argv = normalizeHermesArgv([...args.argv], [], args.shell) + if (!argv) { + return null + } + return finalizeQueryPlan(argv, args) +} + +function finalizeQueryPlan( + argv: string[], + args: { + prompt: string + agentEnv?: Record | null + platform: NodeJS.Platform + shell: AgentStartupShell + } +): { command: string; env: Record } | null { const command = buildQueryCommand(argv, args.shell) const env = { ...args.agentEnv, diff --git a/src/shared/legacy-agent-prefix-tokenizer.test.ts b/src/shared/legacy-agent-prefix-tokenizer.test.ts new file mode 100644 index 00000000000..4ab418ff297 Binary files /dev/null and b/src/shared/legacy-agent-prefix-tokenizer.test.ts differ diff --git a/src/shared/legacy-agent-prefix-tokenizer.ts b/src/shared/legacy-agent-prefix-tokenizer.ts new file mode 100644 index 00000000000..28a7e8a2c9e --- /dev/null +++ b/src/shared/legacy-agent-prefix-tokenizer.ts @@ -0,0 +1,232 @@ +// Read adapter for the legacy built-in command-prefix override (settings +// `agentCmdOverrides` values). At HEAD nothing tokenizes this raw string: +// `resolveBaseCommand` in tui-agent-startup.ts concatenates it as shell text and +// the target shell splits it. This adapter reproduces each installed override's +// CURRENT per-target-shell meaning so launch and the built-in duplication +// equivalence gate can read it as structured argv instead of raw text. +// +// Per-shell grammar (grouping only — no expansion, substitution, or globbing): +// - posix: whitespace splits; single quotes literal-group; double quotes group +// with `\" \\ \$ \`` backslash escapes; backslash outside quotes escapes the +// next char. +// - powershell: whitespace splits with double- AND single-quote grouping; +// backslashes are literal; the U+2018-U+201B smart-quote class groups like '. +// - cmd: whitespace splits with double-quote grouping only; backslashes literal; +// single quotes are ordinary characters. +// All shells reject NUL/control chars and unquoted shell operators so these +// overrides stay visible for Settings repair rather than being reinterpreted. + +import type { AgentStartupShell } from './tui-agent-startup-shell' + +export type LegacyAgentPrefixTokenizeResult = + | { ok: true; tokens: string[] } + | { ok: false; reason: 'unterminated_quote' | 'shell_operator' | 'control_char' } + +const SEPARATORS = new Set([' ', '\t', '\r', '\n']) + +// Unquoted occurrences of these route the override to Settings repair rather +// than being executed or split (launch maps this to invalid_command_override). +const OPERATOR_CHARS = new Set(['&', '|', ';', '<', '>']) + +// PowerShell groups the ASCII apostrophe and the U+2018-U+201B smart-quote class +// as one interchangeable single-quote delimiter class. +const POWERSHELL_SINGLE_QUOTES = new Set(["'", '‘', '’', '‚', '‛']) + +function isDisallowedControl(char: string): boolean { + const code = char.charCodeAt(0) + // NUL, C0 (minus tab/CR/LF which act as separators), DEL, C1. + if (code === 0x00 || code === 0x7f) { + return true + } + if (code < 0x20) { + return char !== '\t' && char !== '\r' && char !== '\n' + } + return code >= 0x80 && code <= 0x9f +} + +type QuoteScan = + | { ok: true; value: string; nextIndex: number } + | { ok: false; reason: 'unterminated_quote' | 'control_char' } + +/** Literal-group scan until any closing delimiter in `closers`; no escapes. */ +function scanLiteralQuote(input: string, openIndex: number, closers: Set): QuoteScan { + let value = '' + let i = openIndex + 1 + while (i < input.length) { + const inner = input[i] + if (closers.has(inner)) { + return { ok: true, value, nextIndex: i + 1 } + } + if (isDisallowedControl(inner)) { + return { ok: false, reason: 'control_char' } + } + value += inner + i += 1 + } + return { ok: false, reason: 'unterminated_quote' } +} + +/** POSIX double-quote scan: only `\" \\ \$ \`` drop the backslash; any other + * backslash stays literal alongside the following character. */ +function scanPosixDoubleQuote(input: string, openIndex: number): QuoteScan { + let value = '' + let i = openIndex + 1 + while (i < input.length) { + const inner = input[i] + if (inner === '"') { + return { ok: true, value, nextIndex: i + 1 } + } + if (isDisallowedControl(inner)) { + return { ok: false, reason: 'control_char' } + } + if (inner === '\\' && i + 1 < input.length) { + const next = input[i + 1] + if (next === '"' || next === '\\' || next === '$' || next === '`') { + value += next + i += 2 + continue + } + } + value += inner + i += 1 + } + return { ok: false, reason: 'unterminated_quote' } +} + +type ShellGrammar = { + /** Chars that open a literal single-quote group, mapped to their closer set. */ + singleQuoteOpeners: Map> + /** Double quotes group; posix additionally decodes backslash escapes inside. */ + posixDoubleQuoteEscapes: boolean + /** Outside quotes, backslash escapes the next char (posix only). */ + backslashEscapesOutsideQuotes: boolean +} + +const DOUBLE_QUOTE_CLOSERS = new Set(['"']) + +function grammarFor(shell: AgentStartupShell): ShellGrammar { + if (shell === 'posix') { + return { + singleQuoteOpeners: new Map([["'", new Set(["'"])]]), + posixDoubleQuoteEscapes: true, + backslashEscapesOutsideQuotes: true + } + } + if (shell === 'powershell') { + const openers = new Map>() + for (const opener of POWERSHELL_SINGLE_QUOTES) { + openers.set(opener, POWERSHELL_SINGLE_QUOTES) + } + return { + singleQuoteOpeners: openers, + posixDoubleQuoteEscapes: false, + backslashEscapesOutsideQuotes: false + } + } + // cmd: double-quote grouping only; single quotes and backslashes are literal. + return { + singleQuoteOpeners: new Map(), + posixDoubleQuoteEscapes: false, + backslashEscapesOutsideQuotes: false + } +} + +export function tokenizeLegacyAgentPrefix( + prefix: string, + shell: AgentStartupShell +): LegacyAgentPrefixTokenizeResult { + const grammar = grammarFor(shell) + const tokens: string[] = [] + let current = '' + let hasCurrent = false + let i = 0 + + while (i < prefix.length) { + const char = prefix[i] + + if (SEPARATORS.has(char)) { + if (hasCurrent) { + tokens.push(current) + current = '' + hasCurrent = false + } + i += 1 + continue + } + + if (isDisallowedControl(char)) { + return { ok: false, reason: 'control_char' } + } + + if (OPERATOR_CHARS.has(char)) { + return { ok: false, reason: 'shell_operator' } + } + + if (char === '"') { + const scan = grammar.posixDoubleQuoteEscapes + ? scanPosixDoubleQuote(prefix, i) + : scanLiteralQuote(prefix, i, DOUBLE_QUOTE_CLOSERS) + if (!scan.ok) { + return scan + } + current += scan.value + hasCurrent = true + i = scan.nextIndex + continue + } + + const singleCloser = grammar.singleQuoteOpeners.get(char) + if (singleCloser) { + const scan = scanLiteralQuote(prefix, i, singleCloser) + if (!scan.ok) { + return scan + } + current += scan.value + hasCurrent = true + i = scan.nextIndex + continue + } + + if (char === '\\' && grammar.backslashEscapesOutsideQuotes && i + 1 < prefix.length) { + const next = prefix[i + 1] + if (isDisallowedControl(next)) { + return { ok: false, reason: 'control_char' } + } + current += next + hasCurrent = true + i += 2 + continue + } + + current += char + hasCurrent = true + i += 1 + } + + if (hasCurrent) { + tokens.push(current) + } + return { ok: true, tokens } +} + +const ALL_SHELLS: readonly AgentStartupShell[] = ['posix', 'powershell', 'cmd'] + +function tokensEqual(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((token, index) => token === b[index]) +} + +/** True when the prefix does not read to identical argv under all three shell + * grammars — the built-in duplication equivalence gate. Uniform failures (same + * reason under every grammar) are not ambiguous; the caller's tokenize catches + * them. Any ok/error mix or divergent argv is ambiguous. */ +export function isLegacyAgentPrefixPlatformAmbiguous(prefix: string): boolean { + const results = ALL_SHELLS.map((shell) => tokenizeLegacyAgentPrefix(prefix, shell)) + const [first, ...rest] = results + if (first.ok) { + return !rest.every((other) => other.ok && tokensEqual(first.tokens, other.tokens)) + } + return !rest.every((other) => !other.ok && other.reason === first.reason) +} diff --git a/src/shared/managed-account-base-agents.test.ts b/src/shared/managed-account-base-agents.test.ts new file mode 100644 index 00000000000..ddf7f30dc23 --- /dev/null +++ b/src/shared/managed-account-base-agents.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { baseAgentUsesManagedAccount } from './managed-account-base-agents' + +describe('baseAgentUsesManagedAccount', () => { + it('is true for the Codex and Claude bases that have managed account state', () => { + expect(baseAgentUsesManagedAccount('codex')).toBe(true) + expect(baseAgentUsesManagedAccount('claude')).toBe(true) + }) + + it('is false for bases with no Orca-managed account to override', () => { + expect(baseAgentUsesManagedAccount('aider')).toBe(false) + expect(baseAgentUsesManagedAccount('gemini')).toBe(false) + expect(baseAgentUsesManagedAccount('openclaude')).toBe(false) + }) +}) diff --git a/src/shared/managed-account-base-agents.ts b/src/shared/managed-account-base-agents.ts new file mode 100644 index 00000000000..9f4e5a09628 --- /dev/null +++ b/src/shared/managed-account-base-agents.ts @@ -0,0 +1,13 @@ +import type { BuiltInTuiAgent } from './types' + +// Why: only Codex and Claude have Orca-managed account state (the codex-accounts +// and claude-accounts services). A launch for one of these bases can inject the +// selected managed account's auth/home env (CODEX_HOME, CLAUDE_CONFIG_DIR), which +// an explicit custom-agent env row then overrides — the precedence the editor +// must surface. Every other base has no managed account for a row to override, so +// the managed-account precedence copy would be misleading there. +export const MANAGED_ACCOUNT_BASE_AGENTS = new Set(['codex', 'claude']) + +export function baseAgentUsesManagedAccount(base: BuiltInTuiAgent): boolean { + return MANAGED_ACCOUNT_BASE_AGENTS.has(base) +} diff --git a/src/shared/pi-agent-kind.ts b/src/shared/pi-agent-kind.ts index b3ba1006d8d..253102a2e27 100644 --- a/src/shared/pi-agent-kind.ts +++ b/src/shared/pi-agent-kind.ts @@ -42,9 +42,11 @@ function getLaunchBinary(command: string): string { .replace(/\.(?:cmd|exe|sh)$/, '') } -const PI_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG.pi.launchCmd) -const OMP_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG.omp.launchCmd) -const PRIME_AGENT_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG['prime-agent'].launchCmd) +const PI_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG.pi.launchArgv?.[0] ?? TUI_AGENT_CONFIG.pi.launchCmd) +const OMP_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG.omp.launchArgv?.[0] ?? TUI_AGENT_CONFIG.omp.launchCmd) +const PRIME_AGENT_LAUNCH_BINARY = getLaunchBinary( + TUI_AGENT_CONFIG['prime-agent'].launchArgv?.[0] ?? TUI_AGENT_CONFIG['prime-agent'].launchCmd +) export function detectExplicitPiAgentKindFromCommand( command: string | undefined diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ed235094473..a3cafc72e5a 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,7 +1,6 @@ import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, - SKILL_DELETE_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_INSTALL_CANCEL_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, @@ -40,8 +39,6 @@ export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' a export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY = 'worktree.linked-work-item-context.v1' as const -export const WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY = - 'worktree.github-pr-suppression.v1' as const export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const export const ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY = 'orchestration.federation.v1' as const export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY = @@ -60,9 +57,6 @@ export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY = 'folder-workspace.path-status.v1' as const export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY = 'linear.issue-attribute-filter.v1' as const -export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const -export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE = - 'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.' // Why: signals the host exposes the Agent Session History scanner over RPC // (aiVault.listSessions). Registered unconditionally for every build, so it is a // STATIC capability advertised by getStatus() automatically — NOT a runtime @@ -79,17 +73,6 @@ export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate // treat a preallocated page ID as canonical when this is advertised. export const BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY = 'browser.tab-create-known-id.v1' as const -export const BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY = 'browser.clientHost.v1' as const -export const BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY = - 'browser.clientHost.pageMetadata.v1' as const -export const BROWSER_CLIENT_AUTOMATION_RUNTIME_CAPABILITY = - 'browser.clientHost.automation.v1' as const -// Why: without it a client-placed browser.upload would resolve remote paths on the desktop filesystem, so uploads fail closed instead. -export const BROWSER_CLIENT_FILE_CHANNEL_RUNTIME_CAPABILITY = - 'browser.clientHost.fileChannel.v1' as const -export const BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY = 'network.browserTunnel.v1' as const -export const BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY = - 'network.browserTunnel.executionHosts.v1' as const // Why: hosts without this strip terminal.send's inputKind (zod object drops // unknown keys), so a mobile xterm query reply would land as ordinary // floor-taking input. Mobile must not forward replies unless advertised. @@ -102,8 +85,7 @@ export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parki // terminal creation, so mobile must hide Quick Commands unless both are present. export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const // Why: older hosts strip worktree.create's clientMutationId, so mobile must only -// replay ambiguous cutovers when the host advertises idempotent create support; -// status.worktreeCreateIdempotency carries the optional host retention policy. +// replay ambiguous cutovers when the host advertises idempotent create support. export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'worktree.create-idempotency.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const @@ -112,8 +94,6 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const -export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY = - 'session-tabs.authoritative-inventory.v1' as const export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' @@ -121,15 +101,6 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const -// Why: structured sessions are journal-backed, not PTY-backed, so a client that -// cannot read them must not see them at all — it would render an agent tab it -// can neither display nor drive. The host also refuses every agentSession.* -// method from a connection that does not advertise this. -export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const -// Why: paired structured clients explicitly hold every visible session surface, allowing the host -// to stop provider children after the last surface closes without tying lifetime to a transport. -export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = - 'agent-session.structured.hold.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -138,48 +109,13 @@ export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi- export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = 'Remote file changes require a newer Orca server. Update the HUB and try again.' -export const GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY = 'github.markPRReadyForReview' as const -export const GITHUB_MARK_PR_READY_UPDATE_REQUIRED_MESSAGE = - 'Marking a pull request ready requires a newer Orca server. Update the server and try again.' -export const GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY = - 'gitlab.updateMR.readyForReview.v1' as const -export const GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE = - 'Marking a merge request ready requires a newer Orca server. Update the server and try again.' export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-defaults.v1' as const export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-source-defaults.v1' as const -// Why: older hosts drop automation.list's selector and answer with the whole authority, so a scoped client must not read that as one host's rows. -export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY = - 'automation.list-host-scope.v1' as const -export const AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE = - 'Filtering automations by host requires a newer Orca server. Update the HUB and try again.' -// Why: without server-side owner preconditions a mutation could run against a host the user never saw, so unfenced rows stay view-only. -export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const -export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE = - 'Editing automations on this host requires a newer Orca server. Update the HUB and try again.' -export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = - 'automation.create-idempotency.v1' as const - -// Generic native clients include the CLI and must not claim Electron-only page -// placement support. -export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ - SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, - AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY -] as const - -// Electron clients can decode client-hosted page placement; becoming a page -// host still requires the separate authenticated browser-client lease. -export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ - ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, - BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY -] as const +// Host-owned identity-only agent launches require this negotiated capability +// before paired clients omit command and environment assembly. +export const AGENT_LAUNCH_IDENTITY_RUNTIME_CAPABILITY = 'agent-launch.identity.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -193,12 +129,6 @@ export const RUNTIME_CAPABILITIES = [ ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, BROWSER_SCREENCAST_RUNTIME_CAPABILITY, BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY, - BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, - BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, - BROWSER_CLIENT_AUTOMATION_RUNTIME_CAPABILITY, - BROWSER_CLIENT_FILE_CHANNEL_RUNTIME_CAPABILITY, - BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY, - BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY, 'terminal.binary-stream.v1', 'terminal.multiplex.v1', 'workspace-ports.v1', @@ -207,10 +137,8 @@ export const RUNTIME_CAPABILITIES = [ TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, - WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, - JIRA_USER_FIELDS_RUNTIME_CAPABILITY, AI_VAULT_RUNTIME_CAPABILITY, AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY, TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY, @@ -219,21 +147,17 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, - SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, - GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, - GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY, + AGENT_LAUNCH_IDENTITY_RUNTIME_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CANCEL_CAPABILITY, @@ -241,11 +165,7 @@ export const RUNTIME_CAPABILITIES = [ SKILL_INSTALL_RESULT_V2_CAPABILITY, SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, - SKILL_INSTALL_PROVIDERS_CAPABILITY, - SKILL_DELETE_CAPABILITY, - AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY + SKILL_INSTALL_PROVIDERS_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/remote-workspace-session-projection.test.ts b/src/shared/remote-workspace-session-projection.test.ts index a22f4123d25..d3c99c46361 100644 --- a/src/shared/remote-workspace-session-projection.test.ts +++ b/src/shared/remote-workspace-session-projection.test.ts @@ -108,6 +108,55 @@ describe('remote workspace session projection', () => { }) }) + it('never projects sleeping-session records or their host-private launch config to a remote client', () => { + const session = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: 'repo-a::/srv/app', + tabsByWorktree: { + 'repo-a::/srv/app': [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'repo-a::/srv/app', + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + // The sleeping record carries the opaque legacy launchConfig (agent env may + // hold secrets). The remote projection omits sleeping records wholesale, so + // neither it nor the host-private launchSnapshot/legacyLaunchConfig can leak. + sleepingAgentSessionsByPaneKey: { + 'pane-1': { + paneKey: 'pane-1', + worktreeId: 'repo-a::/srv/app', + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'sess-1' }, + prompt: 'resume me', + state: 'waiting' as const, + capturedAt: 1, + updatedAt: 1, + launchConfig: { agentArgs: '--resume', agentEnv: { SECRET: 'topsecret' } } + } + } + } + + const projected = exportRemoteWorkspaceSession(session, { + isTargetWorktree: (worktreeId) => worktreeId.startsWith('repo-a::') + }) + + expect('sleepingAgentSessionsByPaneKey' in projected).toBe(false) + const serialized = JSON.stringify(projected) + expect(serialized).not.toContain('sleepingAgentSessionsByPaneKey') + expect(serialized).not.toContain('launchConfig') + expect(serialized).not.toContain('launchSnapshot') + expect(serialized).not.toContain('legacyLaunchConfig') + expect(serialized).not.toContain('topsecret') + }) + it('imports active worktree metadata even when the worktree has no terminal tabs', () => { const session = importRemoteWorkspaceSession( { diff --git a/src/shared/resolved-agent-startup-plan.ts b/src/shared/resolved-agent-startup-plan.ts new file mode 100644 index 00000000000..d2da7296219 --- /dev/null +++ b/src/shared/resolved-agent-startup-plan.ts @@ -0,0 +1,206 @@ +// Startup-plan assembly from a host-resolved launch. Consumes ResolvedAgentLaunch +// policy instead of re-indexing TUI_AGENT_CONFIG or re-reading settings: the +// resolver already produced the complete structured argv, so this step only +// appends the prompt per the resolved injection mode and quotes each final argv +// element exactly once for the target shell. Draft delivery lands the prompt +// UNSUBMITTED via the resolved policy's native flag/env, or hands it back as +// draftPrompt for the readiness writer to paste. No caller may reparse, +// interpolate, or concatenate the result. + +import type { ResolvedAgentLaunch } from './agent-launch-host-contract' +import type { AgentStartupPlan } from './tui-agent-startup' +import type { StartupCommandDelivery } from './codex-startup-delivery' +import { + buildShellCommandFromArgv, + clearEnvCommand, + commandSeparator, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { planHermesStartupQueryFromArgv } from './hermes-startup-query' + +export type ResolvedAgentStartupPlanArgs = { + launch: ResolvedAgentLaunch + prompt: string + allowEmptyPromptLaunch?: boolean + launchToken?: string + /** 'draft' lands the prompt UNSUBMITTED; default 'submit'. */ + promptDelivery?: 'submit' | 'draft' + /** Inline draft-flag command ceiling; over it the draft falls back to + * post-ready paste (draftPrompt) rather than dropping the text. Main threads + * STARTUP_COMMAND_TEXT_MAX_CHARS; absent means no ceiling. */ + maxInlineDraftChars?: number +} + +/** Where the prompt lands: appended argv, a trailing command clause (draft + * env-var cleanup), spawn-only env, the post-ready followup/draft text, and any + * delivery override. draftPrompt is set only when the readiness writer paste is + * the delivery mechanism. */ +type PromptParts = { + argvSuffix: string[] + commandSuffix: string + extraEnv: Record + followupPrompt: string | null + draftPrompt: string | null + startupCommandDelivery: StartupCommandDelivery | undefined +} + +const NO_PROMPT: PromptParts = { + argvSuffix: [], + commandSuffix: '', + extraEnv: {}, + followupPrompt: null, + draftPrompt: null, + startupCommandDelivery: undefined +} + +function submitParts(launch: ResolvedAgentLaunch, trimmedPrompt: string): PromptParts { + const mode = launch.policy.promptInjectionMode + if (mode === 'argv') { + const separator = TUI_AGENT_CONFIG[launch.baseAgent].argvPromptSeparator + return { + ...NO_PROMPT, + argvSuffix: separator ? [separator, trimmedPrompt] : [trimmedPrompt], + // Why: codex consumes an argv prompt only after its shell integration is + // ready; matches the legacy plan's delivery selection for the same case. + startupCommandDelivery: launch.baseAgent === 'codex' ? ('shell-ready' as const) : undefined + } + } + if (mode === 'flag-prompt') { + return { ...NO_PROMPT, argvSuffix: ['--prompt', trimmedPrompt] } + } + if (mode === 'flag-prompt-interactive') { + return { ...NO_PROMPT, argvSuffix: ['--prompt-interactive', trimmedPrompt] } + } + if (mode === 'flag-interactive') { + return { ...NO_PROMPT, argvSuffix: ['-i', trimmedPrompt] } + } + // stdin-after-start: bare TUI launch; the readiness writer delivers the prompt. + return { ...NO_PROMPT, followupPrompt: trimmedPrompt } +} + +function draftParts( + launch: ResolvedAgentLaunch, + trimmedPrompt: string, + shell: AgentStartupShell, + maxInlineDraftChars: number +): PromptParts { + const { draftPromptFlag, draftPromptEnvVar } = launch.policy + if (draftPromptFlag) { + const inlineArgv = [...launch.argv, draftPromptFlag, trimmedPrompt] + if (buildShellCommandFromArgv(inlineArgv, shell).length <= maxInlineDraftChars) { + return { + ...NO_PROMPT, + argvSuffix: [draftPromptFlag, trimmedPrompt], + // Why: native draft flags carry user text on argv and must survive + // rc-file startup, same as an argv submit prompt. + startupCommandDelivery: launch.baseAgent === 'codex' ? ('shell-ready' as const) : undefined + } + } + // Oversized inline draft: deliberately deliver via post-ready paste and + // retain the FULL text — never null/truncated/dropped. + return { ...NO_PROMPT, draftPrompt: trimmedPrompt } + } + if (draftPromptEnvVar) { + return { + ...NO_PROMPT, + // Why: clear the prefill var right after launch so the draft never leaks to + // nested shells the agent spawns. + commandSuffix: `${commandSeparator(shell)}${clearEnvCommand(draftPromptEnvVar, shell)}`, + extraEnv: { [draftPromptEnvVar]: trimmedPrompt } + } + } + // No native draft affordance: the readiness writer pastes it unsubmitted. + return { ...NO_PROMPT, draftPrompt: trimmedPrompt } +} + +/** Build the single startup plan for a resolved launch. The prompt (when the + * injection mode takes one) is appended to a disposable argv copy exactly once; + * the immutable snapshot argv is never extended, and the draft env var / prompt + * transport never enters the durable resume config's agentEnv. */ +export function buildAgentStartupPlanFromResolvedLaunch( + args: ResolvedAgentStartupPlanArgs +): AgentStartupPlan | null { + const { launch } = args + const shell = resolveStartupShell(launch.policy.platform, launch.snapshot.target.shell) + const trimmedPrompt = args.prompt.trim() + + if (!trimmedPrompt && !(args.allowEmptyPromptLaunch ?? false)) { + return null + } + + if ( + trimmedPrompt && + (args.promptDelivery ?? 'submit') === 'submit' && + launch.policy.promptInjectionMode === 'hermes-query' + ) { + const queryPlan = planHermesStartupQueryFromArgv({ + argv: [...launch.argv, ...(launch.resumeArgvSuffix ?? [])], + prompt: trimmedPrompt, + agentEnv: { ...launch.agentEnv }, + platform: launch.policy.platform, + shell + }) + if (queryPlan) { + return { + agent: launch.requestedAgent, + // Why: Hermes owns readiness and submission for `chat --query`; the + // prompt travels via the startup-query env, never the paste writer. + launchCommand: queryPlan.command, + expectedProcess: launch.policy.expectedProcess, + followupPrompt: null, + launchConfig: { + agentCommand: buildShellCommandFromArgv(launch.argv, shell), + agentArgs: '', + // Why: the query transport env is spawn-only; only the admitted user + // agent env enters the durable resume config. + agentEnv: { ...launch.agentEnv } + }, + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + env: queryPlan.env + } + } + // Unplannable query (unrecognized argv or over the env-block bound): fall + // through to the readiness-writer paste rather than dropping the prompt. + } + + const parts = !trimmedPrompt + ? NO_PROMPT + : (args.promptDelivery ?? 'submit') === 'draft' + ? draftParts( + launch, + trimmedPrompt, + shell, + args.maxInlineDraftChars ?? Number.POSITIVE_INFINITY + ) + : submitParts(launch, trimmedPrompt) + + // The durable launch config records only the base command (no resume flags), so + // a fresh relaunch never re-resumes a stale session; the resume flags land only + // in the one-shot launchCommand between the base argv and any prompt suffix. + const baseCommand = buildShellCommandFromArgv(launch.argv, shell) + const resumeSuffix = launch.resumeArgvSuffix ?? [] + const finalArgv = [...launch.argv, ...resumeSuffix, ...parts.argvSuffix] + const spawnEnv = { ...launch.agentEnv, ...parts.extraEnv } + return { + agent: launch.requestedAgent, + launchCommand: `${buildShellCommandFromArgv(finalArgv, shell)}${parts.commandSuffix}`, + expectedProcess: launch.policy.expectedProcess, + followupPrompt: parts.followupPrompt, + launchConfig: { + agentCommand: baseCommand, + agentArgs: '', + // Why: only the admitted user agent env enters the durable resume config; + // pane identity/prompt transport env (draft env var) is spawn-only and + // must never persist. + agentEnv: { ...launch.agentEnv } + }, + ...(parts.draftPrompt !== null ? { draftPrompt: parts.draftPrompt } : {}), + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(parts.startupCommandDelivery + ? { startupCommandDelivery: parts.startupCommandDelivery } + : {}), + ...(Object.keys(spawnEnv).length > 0 ? { env: spawnEnv } : {}) + } +} diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index a9d92b5a57d..f0d4e469e7c 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -38,6 +38,8 @@ export type RuntimeClientEvent = identifier: string workspaceId: string } + | { type: 'agentCatalogChanged'; revision: number } + | { type: 'agentReferencesChanged'; revision: number } | { type: 'activateWorktree' repoId: string diff --git a/src/shared/source-control-ai-actions.ts b/src/shared/source-control-ai-actions.ts index d857ad4b61b..e8082588a77 100644 --- a/src/shared/source-control-ai-actions.ts +++ b/src/shared/source-control-ai-actions.ts @@ -89,7 +89,7 @@ function isSafeRecordKey(key: string): boolean { return key !== '' && key !== '__proto__' && key !== 'constructor' && key !== 'prototype' } -function isSourceControlActionId(value: string): value is SourceControlActionId { +export function isSourceControlActionId(value: string): value is SourceControlActionId { return ACTION_ID_SET.has(value) } diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 00515bfa513..7659fc0f651 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -449,6 +449,51 @@ describe('agent_started schema', () => { }) expect(parsed.success).toBe(false) }) + + // Oracle 17: used_custom_agent is an additive-optional host-derived boolean. + it('accepts used_custom_agent true, false, or absent', () => { + const base = { + agent_kind: 'codex', + launch_source: 'sidebar', + request_kind: 'new', + nth_repo_added: 1 + } as const + expect(eventSchemas.agent_started.safeParse({ ...base, used_custom_agent: true }).success).toBe( + true + ) + expect(eventSchemas.agent_started.safeParse({ ...base, used_custom_agent: false }).success).toBe( + true + ) + expect(eventSchemas.agent_started.safeParse(base).success).toBe(true) + }) + + it('rejects a non-boolean used_custom_agent', () => { + const parsed = eventSchemas.agent_started.safeParse({ + agent_kind: 'codex', + launch_source: 'sidebar', + request_kind: 'new', + nth_repo_added: 1, + used_custom_agent: 'yes' + }) + expect(parsed.success).toBe(false) + }) + + // Oracle 17 privacy half: .strict() rejects any custom-agent identity/config + // field by NAME — the boolean marker is the only custom-launch signal allowed. + it.each(['agent_id', 'label', 'command', 'argv', 'env', 'path'])( + 'rejects the forbidden custom-agent field %s via .strict()', + (forbiddenKey) => { + const parsed = eventSchemas.agent_started.safeParse({ + agent_kind: 'codex', + launch_source: 'sidebar', + request_kind: 'new', + nth_repo_added: 1, + used_custom_agent: true, + [forbiddenKey]: 'custom-agent:codex:secret-reviewer' + }) + expect(parsed.success).toBe(false) + } + ) }) describe('agent_prompt_sent schema', () => { diff --git a/src/shared/terminal-quick-commands.ts b/src/shared/terminal-quick-commands.ts index 28d51bc0323..8e58090ffa2 100644 --- a/src/shared/terminal-quick-commands.ts +++ b/src/shared/terminal-quick-commands.ts @@ -1,4 +1,4 @@ -import { isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config' +import { isBuiltInTuiAgent, isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config' import type { TerminalAgentQuickCommand, TerminalCommandQuickCommand, @@ -71,7 +71,16 @@ export function isTerminalAgentQuickCommand( export function supportsTerminalAgentQuickCommand( agent: unknown ): agent is TerminalAgentQuickCommand['agent'] { - return isTuiAgent(agent) && TUI_AGENT_CONFIG[agent].promptInjectionMode !== 'stdin-after-start' + if (!isTuiAgent(agent)) { + return false + } + if (isBuiltInTuiAgent(agent)) { + return TUI_AGENT_CONFIG[agent].promptInjectionMode !== 'stdin-after-start' + } + // Custom ids carry no static config; base capability is validated with the + // live catalog at mutation/launch time. Normalization must neither crash on + // nor drop a persisted custom reference. + return true } export function getTerminalQuickCommandBody(command: TerminalQuickCommand): string { diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 1c455333ea9..994852bbfaf 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -1,5 +1,6 @@ import type { AiVaultSessionTitle } from './ai-vault-session-title' import type { TuiAgent } from './tui-agent' +import type { PersistedLaunchNoticeState } from './agent-launch-contract' // ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ export type TerminalTab = { @@ -43,6 +44,7 @@ export type TerminalTab = { * hook status overrides this once the agent does anything. Plain terminals * and manually-started agents omit it. */ launchAgent?: TuiAgent + launchNotices?: PersistedLaunchNoticeState /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a * TerminalPane remount, the fresh PTY that results is caused by navigation, * not by the user doing work. Without this flag the resulting diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 664c0e39106..5d2a4f81b49 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from './tui-agent' +import type { BuiltInTuiAgent, TuiAgent } from './tui-agent' import { getOrcaCliCommandNameForPlatform } from './orca-cli-command-name' export type AgentPromptInjectionMode = @@ -26,6 +26,9 @@ export type TuiAgentConfig = { /** Detection runtimes where this launch mode is not available as a detected agent. */ detectUnsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] launchCmd: string + /** Structured executable argv for integrations that must avoid shell reparsing. */ + launchArgv?: readonly [string, ...string[]] + launchArgvByPlatform?: Partial> /** Platform-specific launch command when the public binary name differs. */ launchCmdByPlatform?: Partial> expectedProcess: string @@ -42,8 +45,6 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number - /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ - submitRetryDelayMs?: number /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ windowsShiftEnterEncoding?: 'csi-u' /** Paste newlines for TUIs that read Windows console input records instead of VT paste frames. */ @@ -52,23 +53,11 @@ export type TuiAgentConfig = { ctrlEnterEncoding?: 'csi-u' } -/** Authoring form: `launchCmd` and `expectedProcess` default to `detectCmd` (true for most agents). */ -type TuiAgentConfigSource = Omit & { - launchCmd?: string - expectedProcess?: string -} - -function resolveTuiAgentConfig(source: TuiAgentConfigSource): TuiAgentConfig { - return { - ...source, - launchCmd: source.launchCmd ?? source.detectCmd, - expectedProcess: source.expectedProcess ?? source.detectCmd - } -} - -const TUI_AGENT_CONFIG_SOURCE: Record = { +export const TUI_AGENT_CONFIG: Record = { claude: { detectCmd: 'claude', + launchCmd: 'claude', + expectedProcess: 'claude', promptInjectionMode: 'argv', // Why: `claude --prefill ` seeds the input without submitting, avoiding the paste-after-ready race (PR https://github.com/stablyai/orca/pull/926). draftPromptFlag: '--prefill' @@ -91,24 +80,31 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, openclaude: { detectCmd: 'openclaude', + launchCmd: 'openclaude', + expectedProcess: 'openclaude', promptInjectionMode: 'argv', draftPromptFlag: '--prefill' }, codex: { detectCmd: 'codex', + launchCmd: 'codex', + expectedProcess: 'codex', promptInjectionMode: 'argv', windowsInputRecordPasteNewline: 'alt-enter', preflightTrust: 'codex', draftPasteReadySignal: 'codex-composer-prompt', - draftPasteReadyTimeoutMs: 20_000, - submitRetryDelayMs: 1200 + draftPasteReadyTimeoutMs: 20_000 }, autohand: { detectCmd: 'autohand', + launchCmd: 'autohand', + expectedProcess: 'autohand', promptInjectionMode: 'stdin-after-start' }, ante: { detectCmd: 'ante', + launchCmd: 'ante', + expectedProcess: 'ante', // Why: `ante --prompt` is headless (runs once and exits), so launch the bare TUI and inject after startup. promptInjectionMode: 'stdin-after-start' }, @@ -116,6 +112,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // Why: the unrelated open-source bytedance/trae-agent also installs a `trae-cli` // binary, so detect TRAE CN's CLI on `traecli`, an alias only TRAE CN ships. detectCmd: 'traecli', + launchCmd: 'traecli', + expectedProcess: 'traecli', // Why: `traecli [prompt]` takes the task as a positional argv, same as Claude/Codex. promptInjectionMode: 'argv', // Why: separator so prompts starting with `help`/`config`/`-…` aren't parsed as a @@ -124,18 +122,25 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, opencode: { detectCmd: 'opencode', + launchCmd: 'opencode', + expectedProcess: 'opencode', promptInjectionMode: 'flag-prompt', // Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands. draftPasteReadySignal: 'render-cursor-after-bracketed-paste' }, 'mimo-code': { detectCmd: 'mimo', + launchCmd: 'mimo', + expectedProcess: 'mimo', promptInjectionMode: 'flag-prompt', // Why: mirrors opencode's cursor-gated signal by parity; mimo's startup stream isn't separately validated. draftPasteReadySignal: 'render-cursor-after-bracketed-paste' }, pi: { detectCmd: 'pi', + launchCmd: 'pi', + launchArgv: ['pi'], + expectedProcess: 'pi', promptInjectionMode: 'argv', // Why: pi has no `--prefill` and paste-after-ready races its long startup; the orca-prefill extension seeds this env var instead. draftPromptEnvVar: 'ORCA_PI_PREFILL', @@ -144,6 +149,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, omp: { detectCmd: 'omp', + launchCmd: 'omp', + launchArgv: ['omp'], + expectedProcess: 'omp', promptInjectionMode: 'argv', draftPromptEnvVar: 'ORCA_OMP_PREFILL', // Why: OMP wraps Pi's TUI, so the bytes land in a Pi reader that decodes CSI-u (see pi above). @@ -151,6 +159,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, 'prime-agent': { detectCmd: 'prime-agent', + launchCmd: 'prime-agent', + launchArgv: ['prime-agent'], + expectedProcess: 'prime-agent', // Why: `prime-agent [options] [@files...] [message...]` takes the task as positional argv. promptInjectionMode: 'argv', // Why: separator so prompts starting with `help`/`agents`/`-…` aren't parsed as a @@ -161,26 +172,38 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, gemini: { detectCmd: 'gemini', + launchCmd: 'gemini', + expectedProcess: 'gemini', promptInjectionMode: 'flag-prompt-interactive' }, antigravity: { detectCmd: 'agy', + launchCmd: 'agy', + expectedProcess: 'agy', promptInjectionMode: 'flag-prompt-interactive' }, aider: { detectCmd: 'aider', + launchCmd: 'aider', + expectedProcess: 'aider', promptInjectionMode: 'stdin-after-start' }, goose: { detectCmd: 'goose', + launchCmd: 'goose', + expectedProcess: 'goose', promptInjectionMode: 'stdin-after-start' }, amp: { detectCmd: 'amp', + launchCmd: 'amp', + expectedProcess: 'amp', promptInjectionMode: 'stdin-after-start' }, kilo: { detectCmd: 'kilo', + launchCmd: 'kilo', + expectedProcess: 'kilo', promptInjectionMode: 'stdin-after-start' }, kiro: { @@ -188,23 +211,32 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { detectCmd: 'kiro-cli', // Why: trust flags like --trust-all-tools attach to Kiro's `chat` subcommand, not top-level kiro-cli. launchCmd: 'kiro-cli chat --tui', + expectedProcess: 'kiro-cli', promptInjectionMode: 'stdin-after-start' }, crush: { detectCmd: 'crush', + launchCmd: 'crush', + expectedProcess: 'crush', promptInjectionMode: 'stdin-after-start' }, aug: { // Why: @augmentcode/auggie installs a binary named `auggie`, not `aug`; keep id 'aug' for stored prefs. detectCmd: 'auggie', + launchCmd: 'auggie', + expectedProcess: 'auggie', promptInjectionMode: 'stdin-after-start' }, cline: { detectCmd: 'cline', + launchCmd: 'cline', + expectedProcess: 'cline', promptInjectionMode: 'stdin-after-start' }, codebuff: { detectCmd: 'codebuff', + launchCmd: 'codebuff', + expectedProcess: 'codebuff', promptInjectionMode: 'stdin-after-start' }, 'command-code': { @@ -212,21 +244,28 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { detectCmd: 'command-code', // Why: `--trust` skips the first-run trust prompt so it doesn't consume the task text. launchCmd: 'command-code --trust', + expectedProcess: 'command-code', promptInjectionMode: 'argv' }, continue: { // Why: Continue's CLI binary is `cn`; `continue` is a bash/zsh builtin and would resolve to the shell keyword. detectCmd: 'cn', + launchCmd: 'cn', + expectedProcess: 'cn', promptInjectionMode: 'stdin-after-start' }, cursor: { detectCmd: 'cursor-agent', + launchCmd: 'cursor-agent', + expectedProcess: 'cursor-agent', promptInjectionMode: 'argv', // Why: first-launch trust menu swallows the bracketed paste; pre-write the .workspace-trusted marker so it skips (agent-trust-presets.ts). preflightTrust: 'cursor' }, droid: { detectCmd: 'droid', + launchCmd: 'droid', + expectedProcess: 'droid', promptInjectionMode: 'argv', // Why: Droid decodes CSI-u on Windows; the legacy Esc+CR fallback reads as Enter and submits instead of newline. windowsShiftEnterEncoding: 'csi-u', @@ -234,36 +273,49 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, kimi: { detectCmd: 'kimi', + launchCmd: 'kimi', + expectedProcess: 'kimi', promptInjectionMode: 'stdin-after-start' }, 'mistral-vibe': { // Why: installer exposes binary `vibe` though the package is mistral-vibe; keep old name as alias for wrapped installs. detectCmd: 'vibe', detectCmdAliases: ['mistral-vibe'], + launchCmd: 'vibe', + expectedProcess: 'vibe', promptInjectionMode: 'stdin-after-start' }, 'qwen-code': { // Why: package is qwen-code but its installed CLI binary on PATH is `qwen`. detectCmd: 'qwen', + launchCmd: 'qwen', + expectedProcess: 'qwen', promptInjectionMode: 'stdin-after-start' }, rovo: { detectCmd: 'rovo', + launchCmd: 'rovo', + expectedProcess: 'rovo', promptInjectionMode: 'stdin-after-start' }, hermes: { detectCmd: 'hermes', // Why: bare `hermes` opens the classic REPL; `--tui` starts the full-screen agent UI Orca hosts. launchCmd: 'hermes --tui', + expectedProcess: 'hermes', // Why: Hermes delivers the prompt via its startup-query contract, submitting only after the composer is ready. promptInjectionMode: 'hermes-query' }, openclaw: { detectCmd: 'openclaw', + launchCmd: 'openclaw', + expectedProcess: 'openclaw', promptInjectionMode: 'stdin-after-start' }, copilot: { detectCmd: 'copilot', + launchCmd: 'copilot', + expectedProcess: 'copilot', // Why: `--prompt` exits on completion (kills the hosted session); `-i/--interactive` keeps it interactive. promptInjectionMode: 'flag-interactive', // Why: first-launch trust menu swallows the bracketed paste; pre-write trust so it skips (see agent-trust-presets.ts). @@ -271,6 +323,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, grok: { detectCmd: 'grok', + launchCmd: 'grok', + expectedProcess: 'grok', // Why: argv (grok takes a positional prompt) so multi-line/special-char text isn't mangled as raw PTY keystrokes. promptInjectionMode: 'argv', // Why: separator so prompts like `help`/`--version` aren't parsed as Grok CLI syntax. @@ -283,22 +337,29 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, devin: { detectCmd: 'devin', + launchCmd: 'devin', + expectedProcess: 'devin', // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. promptInjectionMode: 'stdin-after-start' } } -export const TUI_AGENT_CONFIG: Record = Object.fromEntries( - Object.entries(TUI_AGENT_CONFIG_SOURCE).map(([agent, source]) => [ - agent, - resolveTuiAgentConfig(source) - ]) -) as Record - export function isTuiAgent(value: unknown): value is TuiAgent { + return isBuiltInTuiAgent(value) || isWellFormedCustomTuiAgentId(value) +} + +export function isBuiltInTuiAgent(value: unknown): value is BuiltInTuiAgent { return typeof value === 'string' && Object.hasOwn(TUI_AGENT_CONFIG, value) } +export function isWellFormedCustomTuiAgentId(value: unknown): value is `custom-agent:${BuiltInTuiAgent}:${string}` { + if (typeof value !== 'string') { + return false + } + const match = /^custom-agent:([^:]+):([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/.exec(value) + return Boolean(match && isBuiltInTuiAgent(match[1])) +} + export function getTuiAgentDetectCommands(config: TuiAgentConfig): string[] { return [config.detectCmd, ...(config.detectCmdAliases ?? [])] } @@ -314,3 +375,15 @@ export function getTuiAgentLaunchCommand( } return config.launchCmdByPlatform?.[platform] ?? config.launchCmd } + +export function getTuiAgentLaunchArgv( + config: TuiAgentConfig, + platform: NodeJS.Platform, + opts?: { isRemote?: boolean } +): string[] { + const argv = + opts?.isRemote && platform === 'linux' + ? config.launchArgv + : (config.launchArgvByPlatform?.[platform] ?? config.launchArgv) + return argv ? [...argv] : (config.launchCmdByPlatform?.[platform] ?? config.launchCmd).split(/\s+/) +} diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts index 140ef935626..75d5e8dd8ea 100644 --- a/src/shared/tui-agent-launch-command.ts +++ b/src/shared/tui-agent-launch-command.ts @@ -2,8 +2,9 @@ import { removeOverriddenAgentSessionArgs, resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import { requireBuiltInTuiAgentConfig } from './custom-tui-agents' import type { SessionOptionValue } from './native-chat-session-options' -import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' +import { getTuiAgentLaunchCommand } from './tui-agent-config' import { planAgentCliArgsSuffix, quoteStartupArg, @@ -34,7 +35,7 @@ export function resolveAgentLaunchCommand(args: { const override = args.cmdOverrides[args.agent] const command = override || - getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { + getTuiAgentLaunchCommand(requireBuiltInTuiAgentConfig(args.agent), args.platform, { isRemote: args.isRemote }) const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) diff --git a/src/shared/tui-agent-startup-shell.test.ts b/src/shared/tui-agent-startup-shell.test.ts index 1698e86b08b..a188db04cdc 100644 --- a/src/shared/tui-agent-startup-shell.test.ts +++ b/src/shared/tui-agent-startup-shell.test.ts @@ -148,3 +148,57 @@ describe('one Unix startup dialect', () => { expect(plan?.env?.ORCA_PI_PREFILL).toBe('hello') }) }) + +describe('quoteStartupArg', () => { + describe('cmd', () => { + it('passes the neutral metacharacters & | < > ( ) through unmodified inside quotes', () => { + // Regression: the old quoter caret-escaped inside double quotes, where a + // caret is literal, so "C:\Foo & Bar" reached the program as C:\Foo ^& Bar. + expect(quoteStartupArg('C:\\Foo & Bar', 'cmd')).toBe('"C:\\Foo & Bar"') + expect(quoteStartupArg('a|bd(e)f', 'cmd')).toBe('"a|bd(e)f"') + }) + + it('keeps Windows backslashes literal', () => { + expect(quoteStartupArg('C:\\Users\\me\\bin\\codex.exe', 'cmd')).toBe( + '"C:\\Users\\me\\bin\\codex.exe"' + ) + }) + }) + + describe('powershell', () => { + it('doubles ASCII single quotes', () => { + expect(quoteStartupArg("it's", 'powershell')).toBe("'it''s'") + }) + + it('doubles the U+2018-U+201B delimiter class PowerShell also treats as quotes', () => { + expect(quoteStartupArg('a‘b', 'powershell')).toBe("'a‘‘b'") + expect(quoteStartupArg('a’b', 'powershell')).toBe("'a’’b'") + expect(quoteStartupArg('a‚b', 'powershell')).toBe("'a‚‚b'") + expect(quoteStartupArg('a‛b', 'powershell')).toBe("'a‛‛b'") + }) + + it('keeps backslashes and other metacharacters literal', () => { + expect(quoteStartupArg('C:\\Users\\me', 'powershell')).toBe("'C:\\Users\\me'") + expect(quoteStartupArg('$env:PATH;&|', 'powershell')).toBe("'$env:PATH;&|'") + }) + }) + + describe('posix', () => { + it('single-quotes with the standard quote-splice escape', () => { + expect(quoteStartupArg("it's", 'posix')).toBe(`'it'\\''s'`) + expect(quoteStartupArg('a $VAR `cmd` "x"', 'posix')).toBe(`'a $VAR \`cmd\` "x"'`) + }) + }) +}) + +describe('buildShellCommandFromArgv', () => { + it('quotes each element exactly once per target shell', () => { + expect(buildShellCommandFromArgv(['/opt/my tools/codex', '--model', 'x y'], 'posix')).toBe( + `'/opt/my tools/codex' '--model' 'x y'` + ) + expect(buildShellCommandFromArgv(['codex', '--flag'], 'powershell')).toBe(`& 'codex' '--flag'`) + expect(buildShellCommandFromArgv(['C:\\a & b\\codex.exe', '--flag'], 'cmd')).toBe( + '"C:\\a & b\\codex.exe" "--flag"' + ) + }) +}) diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts index 94df0f80ac5..faf25aff41f 100644 --- a/src/shared/tui-agent-startup-shell.ts +++ b/src/shared/tui-agent-startup-shell.ts @@ -217,14 +217,30 @@ function quotePortableUnixArg(value: string): string { export function quoteStartupArg(value: string, shell: AgentStartupShell): string { if (shell === 'powershell') { - return `'${value.replace(/'/g, "''")}'` + // Why: PowerShell treats the Unicode quotation marks U+2018-U+201B as + // single-quote string delimiters exactly like ASCII ' — all five must be + // doubled or a smart quote in a path/prompt terminates the string early. + return `'${value.replace(/(['‘’‚‛])/g, '$1$1')}'` } if (shell === 'cmd') { - return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"` + // Why: inside cmd double quotes a caret is a LITERAL character, so the old + // caret-escaping corrupted data ("C:\Foo & Bar" reached the program as + // C:\Foo ^& Bar). & | < > ( ) are neutral inside the quotes and must pass + // through unchanged. %…%/delayed-! expansion and embedded " still apply + // inside cmd quotes and cannot be encoded faithfully — resolver-managed + // launches reject custom-supplied elements containing % ! " ^ before any + // writer runs (cmd_metachar); this quoter passes them through as-is. + return `"${value}"` } return quotePortableUnixArg(value) } +/** Characters cmd cannot faithfully deliver inside a double-quoted argv element: + * %…% / delayed-! expansion still applies and embedded quotes re-split the + * line. Custom-supplied elements containing one of these fail closed when the + * target shell is cmd. */ +export const CMD_UNENCODABLE_CHAR_RE = /[%!^"]/ + export function buildShellCommandFromArgv( args: readonly string[], shell: AgentStartupShell @@ -377,6 +393,9 @@ export function planAgentCliArgsSuffix( if (!trimmed) { return { ok: true, suffix: '' } } + // Shell-aware tokenization (#7862): posix keeps the shared grammar, Windows + // shells keep backslashes literal (same grammar the resolver's built-in + // args band uses). const tokenized = tokenizeStartupCommand(trimmed, shell) if (!tokenized.ok) { return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` } diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index e5e26f46802..8acd0bc9843 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -73,16 +73,20 @@ describe('tui agent startup plans', () => { ) }) - it('uses cmd escaping when requested explicitly', () => { + it('uses cmd quoting that keeps neutral metacharacters as data', () => { + // Why: carets inside cmd double quotes are literal characters, so the old + // caret-escaping corrupted every &/|/<>/() in the payload. Neutral + // metacharacters now pass through; % ! " ^ remain cmd-unencodable and the + // launch resolver fails closed on custom-supplied elements containing them. const plan = buildAgentStartupPlan({ agent: 'claude', - prompt: 'fix "quoted" & %PATH%', + prompt: 'fix the build & tag (v2)', cmdOverrides: {}, platform: 'win32', shell: 'cmd' }) - expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') + expect(plan?.launchCommand).toBe('claude "fix the build & tag (v2)"') }) it('terminates Grok options before a flag-shaped POSIX prompt', () => { diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index fba16776378..78aa7eb1394 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -8,6 +8,7 @@ import { type AgentStartupShell } from './tui-agent-startup-shell' import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { requireBuiltInTuiAgentConfig } from './custom-tui-agents' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' import { planHermesStartupQuery } from './hermes-startup-query' @@ -55,7 +56,7 @@ export function buildAgentStartupPlan(args: { const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() - const config = TUI_AGENT_CONFIG[agent] + const config = requireBuiltInTuiAgentConfig(agent) const usesQuery = config.promptInjectionMode === 'hermes-query' && Boolean(trimmedPrompt) const baseCommand = resolveAgentLaunchCommand({ agent, @@ -205,7 +206,7 @@ export function buildAgentDraftLaunchPlan(args: { }): AgentDraftLaunchPlan | null { const { agent, draft, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) - const config = TUI_AGENT_CONFIG[agent] + const config = requireBuiltInTuiAgentConfig(agent) const trimmed = draft.trim() if (!trimmed) { return null diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index a885e3dd24c..40c1deaef83 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -1,39 +1,9 @@ -/** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace - * flow and for the default-agent setting. Extend this union as new agents are added. */ -export type TuiAgent = - | 'claude' // Claude Code - | 'claude-agent-teams' // Claude Code Agent Teams via Orca native panes - | 'openclaude' // OpenClaude - | 'codex' // OpenAI Codex - | 'autohand' // Autohand Code CLI - | 'opencode' // OpenCode - | 'mimo-code' - | 'pi' // Pi (pi.dev) - | 'omp' // OMP (omp.sh) - | 'gemini' // Gemini CLI - | 'antigravity' // Google Antigravity CLI - | 'aider' // Aider - | 'goose' // Goose - | 'amp' // Amp - | 'kilo' // Kilocode - | 'kiro' // Kiro - | 'crush' // Charm/Crush - | 'aug' // Augment/Auggie - | 'cline' // Cline - | 'codebuff' // Codebuff - | 'command-code' // Command Code - | 'continue' // Continue - | 'cursor' // Cursor - | 'droid' // Factory Droid - | 'kimi' // Kimi - | 'mistral-vibe' // Mistral Vibe - | 'qwen-code' // Qwen Code - | 'rovo' // Rovo Dev - | 'hermes' // Hermes Agent - | 'openclaw' // OpenClaw - | 'copilot' // GitHub Copilot CLI - | 'grok' // xAI Grok CLI - | 'devin' // Devin CLI - | 'ante' // Ante (Antigma Labs) - | 'trae' // Trae CLI - | 'prime-agent' // Prime Agent (Prime Intellect) +import type { + BuiltInTuiAgent, + CustomTuiAgent, + CustomTuiAgentId, + DeletedCustomTuiAgent, + TuiAgent +} from './types' + +export type { BuiltInTuiAgent, CustomTuiAgent, CustomTuiAgentId, DeletedCustomTuiAgent, TuiAgent } diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 00000000000..522b6c15b9b --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,4082 @@ +/* eslint-disable max-lines */ +import type { ExecutionHostId } from './execution-host' +import type { + RemovedSshTargetTombstone, + SshPtyConsumerRecovery, + SshRemotePtyLease, + SshTarget +} from './ssh-types' +import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' +import type { WorkspaceSource } from './workspace-source' +import type { DedicatedRepoChannel, ReleaseBuild, ReleaseChannel } from './release-channel' +import type { GitHubProjectSettings } from './github/project-types' +import type { + AgentStatusState, + AgentType, + MigrationUnsupportedPtyEntry +} from './agent-status-types' +import type { VoiceSettings } from './speech-types' +import type { WorkspaceCleanupUIState } from './workspace-cleanup' +import type { LargeDiffRenderLimit } from './large-diff-render-limit' +import type { GitLabProjectSettings } from './gitlab-types' +import type { TaskProvider } from './task-providers' +import type { FeatureTipId } from './feature-tips' +import type { ContextualTourId } from './contextual-tours' +import type { + FeatureInteractionState, + FeatureInteractionTelemetryBucketState +} from './feature-interactions' +import type { GitBranchChangeStatus } from './git-status-types' +import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings' +import type { RepoIcon } from './repo-icon' +import type { AppIconId } from './app-icon' +import type { + RepoSourceControlAiOverrides, + SourceControlAiSettings +} from './source-control-ai-types' +import type { StartupCommandDelivery } from './codex-startup-delivery' +// Type-only import; the cycle with agent-launch-contract (which imports TuiAgent +// from here) is erased at compile time. +import type { + AgentLaunchFailure, + AgentLaunchReceipt, + AgentLaunchRequestError, + PersistedAgentLaunchFailure, + PersistedLaunchNoticeState +} from './agent-launch-contract' +import type { AgentLaunchSpawnRequest } from './agent-launch-spawn-request' +import type { BackgroundAgentLaunchAttempt } from './background-agent-launch' +import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events' +import type { SleepingAgentLaunchConfig, SleepingAgentSessionRecord } from './agent-session-resume' +import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat' +import type { TerminalCustomTheme } from './terminal-custom-themes' +import type { UiLanguage } from './ui-language' +import type { ForkSyncMode } from './git-fork-sync' +import type { GitRemoteIdentity } from './git-remote-identity' +import type { + GlobalWindowsRuntimeDefault, + LocalWindowsRuntimePreference +} from './project-execution-runtime' +import type { UsagePercentageDisplay } from './usage-percentage-display' +import type { StatusBarUsageMode } from './status-bar-usage-mode' +import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' +import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger' +import type { TaskSourceContext } from './task-source-context' +import type { SetupRunnerShell } from './setup-runner-command' +import type { AiVaultSessionTitle } from './ai-vault-session-title' +import type { ComputerAwakeMode } from './computer-awake-mode' + +// Re-exported for backward compat with renderer call sites that import +// `WorkspaceCreateTelemetrySource` from '../../../shared/types'. +export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './workspace-source' +export type { TaskProvider } from './task-providers' +export type { + GitBranchChangeStatus, + GitConflictKind, + GitConflictOperation, + GitConflictResolutionStatus, + GitConflictStatusSource, + GitFileStatus, + GitStagingArea, + GitStatusEntry, + GitStatusResult, + GitSubmoduleStatus, + GitUncommittedEntry, + GitUpstreamStatus +} from './git-status-types' + +// ─── Shell PATH hydration ──────────────────────────────────────────── +// Why: shared so the main-side `HydrationResult` discriminator and the +// telemetry schema in `telemetry-events.ts` stay in lockstep without +// `src/shared/` taking a forbidden import from `src/main/`. A compile-time +// guard in telemetry-events.ts asserts the schema enum matches this alias — +// adding a new failure mode without updating both places fails the build. +export type ShellHydrationFailureReason = + | 'none' + | 'no_shell' + | 'timeout' + | 'spawn_error' + | 'empty_path' + +export type PathSource = 'shell_hydrate' | 'sync_seed_only' + +// ─── Repo ──────────────────────────────────────────────────────────── +export type RepoKind = 'git' | 'folder' + +/** + * Per-repo user choice for where issues are fetched and filed. + * + * Why three states, not two: storage must distinguish "user explicitly chose + * upstream" from "heuristic happens to resolve to upstream right now." Collapsing + * the two would let a remote-topology change (someone removes `upstream`, or + * adds one later) silently move the effective source — the exact silent-source- + * switch class the upstream-issue-source design rejects. + * + * - `'auto'` (or undefined): honor the heuristic in `getIssueOwnerRepo` + * (upstream-if-exists, else origin). Initial state for every repo. + * - `'upstream'`: explicit upstream. Wins over heuristic and future topology + * changes. Falls back to origin if `upstream` remote vanishes, with a toast. + * - `'origin'`: explicit origin. Same precedence. + */ +export type IssueSourcePreference = 'upstream' | 'origin' | 'auto' +export type { ForkSyncMode, GitForkSyncExpectedUpstream, GitForkSyncResult } from './git-fork-sync' +export type ExternalWorktreeVisibility = 'hide' | 'show' + +export type ProjectProviderIdentity = { + provider: 'github' + owner: string + repo: string + host?: string +} + +export type Project = { + id: string + displayName: string + badgeColor: string + repoIcon?: RepoIcon | null + kind?: RepoKind + providerIdentity?: ProjectProviderIdentity + gitRemoteIdentity?: GitRemoteIdentity + /** Local Windows projects inherit the global runtime default unless this override is set. */ + localWindowsRuntimePreference?: LocalWindowsRuntimePreference + sourceRepoIds: string[] + createdAt: number + updatedAt: number +} + +export type ProjectUpdateArgs = { + projectId: string + updates: Partial> +} + +export type ProjectHostSetupState = 'ready' | 'not-set-up' | 'setting-up' | 'error' | 'unsupported' +export type ProjectHostSetupMethod = + | 'legacy-repo' + | 'imported-existing-folder' + | 'cloned' + | 'provisioned' +export type RepoProjectHostSetupMethod = Extract< + ProjectHostSetupMethod, + 'imported-existing-folder' | 'cloned' +> + +export type ProjectHostSetup = { + id: string + projectId: string + hostId: ExecutionHostId + repoId: string + path: string + displayName: string + kind?: RepoKind + connectionId?: string | null + executionHostId?: ExecutionHostId | null + /** Renderer projection of the paired runtime that owns this setup's transport. */ + runtimeOwnerEnvironmentId?: string + worktreeBasePath?: string + hookSettings?: RepoHookSettings + gitUsername?: string + setupState: ProjectHostSetupState + setupMethod: ProjectHostSetupMethod + sourceControlAi?: RepoSourceControlAiOverrides + createdAt: number + updatedAt: number +} + +export type ProjectHostSetupExistingFolderArgs = { + projectId: string + projectProviderIdentity?: ProjectProviderIdentity + hostId: ExecutionHostId + path: string + kind?: RepoKind + displayName?: string + setupMethod?: RepoProjectHostSetupMethod +} + +export type ProjectHostSetupCreateArgs = { + projectId: string + hostId: ExecutionHostId + setupId?: string + path?: string + kind?: RepoKind + displayName?: string + worktreeBasePath?: string + gitUsername?: string + setupState?: ProjectHostSetupState + setupMethod?: Exclude +} + +export type ProjectHostSetupCloneArgs = { + projectId: string + projectProviderIdentity?: ProjectProviderIdentity + hostId: ExecutionHostId + url: string + destination: string + displayName?: string +} + +export type ProjectHostSetupUpdateArgs = { + setupId: string + updates: Partial< + Pick< + ProjectHostSetup, + | 'displayName' + | 'path' + | 'worktreeBasePath' + | 'setupState' + | 'setupMethod' + | 'gitUsername' + | 'kind' + > + > +} + +export type ProjectHostSetupDeleteArgs = { + setupId: string +} + +export type ProjectHostSetupResult = { + project: Project + setup: ProjectHostSetup + repo: Repo +} + +export type ProjectHostSetupCreateResult = { + project: Project + setup: ProjectHostSetup +} + +export type ProjectHostSetupUpdateResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + +export type ProjectHostSetupDeleteResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + +export type Repo = { + id: string + path: string + displayName: string + badgeColor: string + repoIcon?: RepoIcon | null + /** Set when the repo is a fork: the upstream/parent owner/repo. Drives the + * fork indicator and the default avatar of same-name forks (renamed forks + * keep their own owner). Absent = not a fork, or fork status not yet + * resolved. */ + upstream?: GitHubRepositoryIdentity | null + addedAt: number + kind?: RepoKind + gitUsername?: string + worktreeBaseRef?: string + /** Optional repo-scoped workspace root override. Relative paths resolve from `path`. */ + worktreeBasePath?: string + hookSettings?: RepoHookSettings + /** SSH target ID for remote repos. null/undefined = local. */ + connectionId?: string | null + /** + * Explicit execution owner for this repo. Runtime-host repos need this + * because they otherwise look identical to local repos (`connectionId: null`). + */ + executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null + /** Per-repo override for issue-source resolution. `undefined` is treated + * identically to `'auto'`; writers leave it undefined on creation so + * existing persisted records stay forward-compatible. */ + issueSourcePreference?: IssueSourcePreference + /** Controls Orca's fork-default-branch sync offer for repos with upstream metadata. */ + forkSyncMode?: ForkSyncMode + /** Canonical identity for the repo remote Orca should use for provider-level grouping. */ + gitRemoteIdentity?: GitRemoteIdentity | null + /** Controls whether worktrees Orca did not create appear in the sidebar. */ + externalWorktreeVisibility?: ExternalWorktreeVisibility + /** True when the repo predates hidden-by-default external worktrees. */ + externalWorktreeVisibilityLegacy?: boolean + /** One-shot guard for the optional existing-user visibility prompt. */ + externalWorktreeVisibilityPromptDismissedAt?: number + /** Hidden external worktree paths acknowledged by Keep hidden on the inbox. */ + externalWorktreeInboxBaselinePaths?: string[] + /** External worktree paths explicitly imported while global visibility stays hide. */ + importedExternalWorktreePaths?: string[] + /** User permanently opted out of the new-external-worktree inbox for this repo. */ + externalWorktreeDiscoverySuppressedAt?: number + /** Paths (relative to the primary checkout) that should be APFS clone-copied + * on macOS when possible, otherwise symlinked, into newly created worktrees. + * Undefined/empty means no shared paths are created for this repo. */ + symlinkPaths?: string[] + /** Durable sidebar-only repo organization. Execution remains repo-scoped. */ + projectGroupId?: string | null + /** User-authored ordering inside the project group or ungrouped bucket. */ + projectGroupOrder?: number + /** Repo-specific source-control AI overrides. Missing fields inherit global settings. */ + sourceControlAi?: RepoSourceControlAiOverrides + /** Transitional source for ProjectHostSetup.setupMethod while Repo remains compatibility storage. */ + projectHostSetupMethod?: RepoProjectHostSetupMethod +} + +export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration' + +export type ProjectGroup = { + id: string + name: string + parentPath: string | null + /** SSH target ID for folder-backed groups imported from a remote root. */ + connectionId?: string | null + /** Renderer-owned host stamp for groups fetched from a runtime environment. */ + executionHostId?: string | null + parentGroupId: string | null + createdFrom: ProjectGroupCreatedFrom + tabOrder: number + isCollapsed: boolean + color: string | null + createdAt: number + updatedAt: number +} + +export type WorkspaceScope = + | { type: 'worktree'; worktreeId: string } + | { type: 'folder'; folderWorkspaceId: string } + +export type WorkspaceKey = `worktree:${string}` | `folder:${string}` + +export type FolderWorkspace = { + id: string + projectGroupId: string + name: string + folderPath: string + /** SSH target ID for folder workspaces whose folder path lives remotely. */ + connectionId?: string | null + /** Renderer-owned host stamp for host-qualified folder catalogs. */ + executionHostId?: ExecutionHostId | null + /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ + creatorProvenance?: WorkspaceCreatorProvenance + linkedTask: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + comment: string + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ + manualOrder?: number + workspaceStatus?: WorkspaceStatus + createdWithAgent?: TuiAgent + pendingFirstAgentMessageRename?: boolean + firstAgentMessageRenameError?: string | null + lastActivityAt: number + createdAt: number + updatedAt: number + diffComments?: DiffComment[] +} + +export type WorkspaceLinkedItem = { + provider: 'github' | 'gitlab' | 'linear' | 'jira' + type: 'issue' | 'pr' | 'mr' + number: number + title: string + url: string + linearIdentifier?: string + jiraIdentifier?: string + repoId?: string +} + +export type FolderWorkspaceLinkedTask = WorkspaceLinkedItem + +export type NestedRepoScanOptions = { + maxDepth?: number + maxRepos?: number + timeoutMs?: number | null +} + +export type NestedRepoCandidate = { + path: string + displayName: string + depth: number +} + +export type NestedRepoScanResult = { + selectedPath: string + selectedPathKind: 'git_repo' | 'non_git_folder' + repos: NestedRepoCandidate[] + truncated: boolean + timedOut: boolean + stopped: boolean + durationMs: number + maxDepth: number + maxRepos: number + timeoutMs: number | null +} + +export type ProjectGroupImportMode = 'group' | 'separate' + +export type ProjectGroupImportProjectResult = { + path: string + projectId?: string + status: 'imported' | 'already-known' | 'failed' + error?: string +} + +export type ProjectGroupImportResult = { + group?: ProjectGroup + projects: ProjectGroupImportProjectResult[] + importedCount: number + alreadyKnownCount: number + failedCount: number +} + +export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' +export type SetupAgentStartupPolicy = 'start-immediately' | 'wait-for-setup' +export type SetupDecision = 'inherit' | 'run' | 'skip' +export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both' + +/** + * Envelope returned by the `repos:getBaseRefDefault` IPC handler. + * + * Why: declared in `shared/` rather than colocated with the handler so the + * preload bridge and renderer can import the same named type. Before this + * lived in `src/main/git/repo.ts` — the preload layer cannot import from + * `src/main/`, which forced three sites to inline the same structural shape + * and risk silent drift. + * + * Why `remoteCount`: BaseRefPicker renders a multi-remote hint when the repo + * has more than one configured remote; piggybacking the count on this IPC + * avoids a second round-trip. + * + * Why `defaultBaseRef` (not `default`): `default` is a reserved word and is + * awkward to destructure. + */ +export type BaseRefDefaultResult = { + defaultBaseRef: string | null + remoteCount: number +} + +export type BaseRefSearchResult = { + refName: string + localBranchName: string +} + +// ─── Worktree (git-level) ──────────────────────────────────────────── +export type GitWorktreeInfo = { + path: string + head: string + branch: string + isBare: boolean + isSparse?: boolean + locked?: boolean + lockReason?: string + /** True when Git reports the worktree as prunable (its directory is gone but + * the registration remains). Detected via the `prunable` porcelain field + * (Git ≥ 2.36) or a path-existence probe on older Git. */ + prunable?: boolean + prunableReason?: string + /** True for the repo's main working tree (the first entry from `git worktree list`). + * Linked worktrees created via `git worktree add` have this set to false. */ + isMainWorktree: boolean +} + +/** Head/branch snapshot read from Git metadata files without spawning Git. + * Carries background-worktree freshness when status-only churn includes a + * real head move (external commit/amend/reset) that must not re-enter the + * structural `worktrees:changed` fanout. */ +export type WorktreeHeadIdentity = { + worktreePath: string + head: string + /** Full ref (e.g. `refs/heads/main`), or null for a detached HEAD. */ + branch: string | null +} + +// ─── Worktree (app-level, enriched) ────────────────────────────────── +export type WorkspaceStatus = string + +export type WorkspaceStatusDefinition = { + id: WorkspaceStatus + label: string + color?: string + icon?: string +} + +export type Worktree = { + id: string // `${repoId}::${path}` + instanceId?: string + repoId: string + /** Durable project identity. Optional while legacy repo-only workspaces migrate. */ + projectId?: string + /** Execution host that owns the workspace. Optional for pre-project-host metadata. */ + hostId?: ExecutionHostId + /** Renderer projection of the paired runtime that transports operations to `hostId`. */ + runtimeOwnerEnvironmentId?: string + /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ + creatorProvenance?: WorkspaceCreatorProvenance + /** Host-specific setup used to create/run this workspace. */ + projectHostSetupId?: string + displayName: string + comment: string + linkedIssue: number | null + linkedPR: number | null + linkedLinearIssue: string | null + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + // Why: parallel slots for non-GitHub work-item references. Kept as separate + // fields (rather than reusing linkedIssue / linkedPR with a provider + // discriminator) so the persistence layer is unambiguous when a user + // has remotes from several providers on the same repo, and so the + // existing GitHub renderer code keeps reading linkedPR / linkedIssue + // unchanged. Optional on the type so existing test fixtures and + // persisted older worktrees that never carried these fields continue + // to typecheck and load without migration. + linkedGitLabMR?: number | null + linkedGitLabIssue?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ + manualOrder?: number + lastActivityAt: number + /** Set once when Orca creates the worktree. Absent for worktrees discovered + * on disk or persisted before this field existed. Used by the sidebar to + * grant newly-created worktrees a short grace window at the top of Recent, + * immune to ambient PTY-bump reordering in other worktrees. */ + createdAt?: number + /** Agent selected when Orca originally created the worktree. Used only to + * seed a replacement terminal if the user later reopens the worktree after + * closing every visible surface. */ + createdWithAgent?: TuiAgent + /** True while an auto-named workspace is waiting for the first agent message + * to drive the branch/title rename. */ + pendingFirstAgentMessageRename?: boolean + /** Holds the last auto-rename generation failure message so the sidebar can + * show a "rename failed" badge. null/undefined when there is no failure + * (never attempted, succeeded, or only a benign skip). */ + firstAgentMessageRenameError?: string | null + sparseDirectories?: string[] + sparseBaseRef?: string + /** ID of the saved preset this worktree was created from, if any. Cleared + * when the worktree is no longer sparse on refresh. */ + sparsePresetId?: string + /** Intended create base for stale-base probes. Persisted metadata, not UI drift state. */ + baseRef?: string + /** Remote/branch Orca should publish review commits to when it created this worktree. */ + pushTarget?: GitPushTarget + /** Path-derived worktree ids this worktree had before folder renames. */ + priorWorktreeIds?: string[] + workspaceStatus?: WorkspaceStatus + diffComments?: DiffComment[] + mobileDiffReview?: MobileDiffReviewState + automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance + /** Client-safe mirror of {@link WorktreeMeta.pendingAgentLaunch} for the + * post-create recovery card; carries only anti-race guards and display + * attribution, never the private launch snapshot or token. */ + pendingAgentLaunch?: WorktreeMeta['pendingAgentLaunch'] + /** Client-safe mirror of {@link WorktreeMeta.agentLaunchFailure}: the durable + * post-create failure the recovery card renders. Only codes + repair hints. */ + agentLaunchFailure?: PersistedAgentLaunchFailure + /** Client-safe projection of the SEPARATE generic background-attempt store + * (U6), filtered to attempts targeting this worktree. Distinct from the + * interactive two-stage `pendingAgentLaunch`/`agentLaunchFailure` above so an + * unattended background failure survives reload and renders its own recovery + * card without conflating the two. Omitted when there are none. */ + backgroundAgentLaunches?: BackgroundAgentLaunchAttempt[] +} & GitWorktreeInfo + +/** Provenance for workspaces created through `orca worktree create`. Absent on + * workspaces created before this field existed and on every non-CLI create, so + * consumers must read "missing" as "not CLI-created". */ +export type CliWorkspaceProvenance = { + kind: 'created-by-cli' + createdAt: number + /** Orca terminal the CLI ran inside, when the caller had one — distinguishes + * an agent-issued create from one hand-typed in an external shell. */ + callerTerminalHandle?: string + /** Agent requested via `--agent`, when one was passed. */ + startupAgent?: TuiAgent +} + +export type WorkspaceCreatorProvenance = + | { kind: 'host' } + | { kind: 'paired-device'; deviceId: string } + +export type AutomationWorkspaceProvenance = { + kind: 'created-by-automation' + automationId: string + automationNameSnapshot: string + automationRunId: string + automationRunTitleSnapshot: string + createdAt: number + executionTargetType: AutomationExecutionTargetType + executionTargetId: string + projectId: string + repoId?: string + hostId?: ExecutionHostId +} + +export type AutomationWorkspaceProvenanceRequest = { + automationId: string + automationRunId: string + dispatchToken: string + createRequestId: string +} + +export type GitPushTarget = { + remoteName: string + branchName: string + remoteUrl?: string + /** True when Orca added this remote while preparing a fork-PR worktree. */ + remoteCreated?: boolean +} + +export type GitHubPrStartPoint = { + baseBranch: string + /** Review target branch to use for Source Control compare after creating from a PR head SHA. */ + compareBaseRef?: string + pushTarget?: GitPushTarget + /** Verified PR head commit. Present when checkout can be tied to a stable SHA. */ + headSha?: string + /** Exact local branch name to create/reuse when the PR head is a safe same-repo branch. */ + branchNameOverride?: string + /** Fork PRs: false when "Allow edits from maintainers" is off; a push to the fork may be rejected. */ + maintainerCanModify?: boolean +} + +// ─── Worktree metadata (persisted user-authored fields only) ───────── +export type WorktreeMeta = { + /** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */ + instanceId?: string + /** See Worktree.projectId. Persisted for project-first workspace ownership. */ + projectId?: string + /** See Worktree.hostId. Persisted for project-first workspace ownership. */ + hostId?: ExecutionHostId + /** See Worktree.projectHostSetupId. Persisted for project-first workspace ownership. */ + projectHostSetupId?: string + /** See Worktree.creatorProvenance. */ + creatorProvenance?: WorkspaceCreatorProvenance + displayName: string + comment: string + linkedIssue: number | null + linkedPR: number | null + linkedLinearIssue: string | null + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + /** Optional for backward compatibility — see Worktree.linkedGitLabMR. */ + linkedGitLabMR?: number | null + /** Optional for backward compatibility — see Worktree.linkedGitLabIssue. */ + linkedGitLabIssue?: number | null + /** Optional for backward compatibility — see Worktree.linkedBitbucketPR. */ + linkedBitbucketPR?: number | null + /** Optional for backward compatibility — see Worktree.linkedAzureDevOpsPR. */ + linkedAzureDevOpsPR?: number | null + /** Optional for backward compatibility — see Worktree.linkedGiteaPR. */ + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ + manualOrder?: number + lastActivityAt: number + /** See {@link Worktree.createdAt}. Persisted to orca-data.json. */ + createdAt?: number + /** See {@link Worktree.createdWithAgent}. Persisted to orca-data.json. */ + createdWithAgent?: TuiAgent + /** See {@link Worktree.pendingFirstAgentMessageRename}. */ + pendingFirstAgentMessageRename?: boolean + /** See {@link Worktree.firstAgentMessageRenameError}. */ + firstAgentMessageRenameError?: string | null + /** In-flight/committed agent launch for a two-stage creation transaction. + * Client-safe: `operationId`/`priorFailureId` are anti-race guards (not + * secrets, they appear in client metadata), `requestedAgent` is display + * attribution. The private launch snapshot and token live only in the host + * operation store and never enter this record. */ + pendingAgentLaunch?: { + operationId: string + requestedAgent: TuiAgent + priorFailureId?: string + } + /** Durable failure from a post-create final-resolution or spawn failure. The + * workspace is retained and Retry / Choose agent recover from this record; + * a successful (re)launch clears it. */ + agentLaunchFailure?: PersistedAgentLaunchFailure + sparseDirectories?: string[] + sparseBaseRef?: string + sparsePresetId?: string + /** Intended create base for stale-base probes. Persisted metadata, not UI drift state. */ + baseRef?: string + /** True when Orca checked out a pre-existing local branch that delete must not prune. */ + preserveBranchOnDelete?: boolean + /** See {@link Worktree.pushTarget}. Persisted so refreshed worktree lists keep the target. */ + pushTarget?: GitPushTarget + /** Explicit marker stamped when Orca creates the worktree. */ + orcaCreatedAt?: number + orcaCreationSource?: 'desktop' | 'runtime' | 'cli' | 'ssh' + /** Workspace layout active when Orca created the worktree. */ + orcaCreationWorkspaceLayout?: OrcaWorkspaceLayout + /** User-assigned workspace board status for manual sidebar organization. */ + workspaceStatus?: WorkspaceStatus + diffComments?: DiffComment[] + /** Path-derived worktree ids this worktree had before its folder was renamed + * on disk (the id embeds the path). Lets the daemon's session GC and registry + * hydration recognize sessions minted under an old id instead of reaping + * them. Self-prunes when the worktree is deleted. */ + priorWorktreeIds?: string[] + mobileDiffReview?: MobileDiffReviewState + /** System-owned provenance for workspaces created by automation new-per-run dispatches. */ + automationProvenance?: AutomationWorkspaceProvenance + /** System-owned provenance for workspaces created via `orca worktree create`. */ + cliProvenance?: CliWorkspaceProvenance +} + +export type WorktreeOwnership = 'orca-managed' | 'external' | 'unknown-legacy' | 'agent-scratch' + +export type DetectedWorktreeListSource = 'git' | 'metadata-fallback' | 'session-fallback' + +export type DetectedWorktree = Worktree & { + ownership: WorktreeOwnership + selectedCheckout: boolean + visible: boolean +} + +export type DetectedWorktreeListResult = { + repoId: string + authoritative: boolean + source: DetectedWorktreeListSource + worktrees: DetectedWorktree[] +} + +export type WorktreeLineageOrigin = 'orchestration' | 'cli' | 'manual' +export type WorktreeLineageCaptureConfidence = 'explicit' | 'inferred' +export type WorktreeLineageCaptureSource = + | 'explicit-cli-flag' + | 'env-workspace' + | 'cwd-context' + | 'terminal-context' + | 'orchestration-context' + | 'active-workspace' + | 'manual-action' + +export type WorktreeLineageCapture = { + source: WorktreeLineageCaptureSource + confidence: WorktreeLineageCaptureConfidence +} + +export type WorktreeLineage = { + worktreeId: string + worktreeInstanceId: string + parentWorktreeId: string + parentWorktreeInstanceId: string + origin: WorktreeLineageOrigin + capture: WorktreeLineageCapture + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + createdAt: number +} + +export type WorkspaceLineage = { + childWorkspaceKey: WorkspaceKey + childInstanceId?: string | null + parentWorkspaceKey: WorkspaceKey + parentInstanceId?: string | null + origin: WorktreeLineageOrigin + capture: WorktreeLineageCapture + taskId?: string + orchestrationRunId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + createdAt: number +} + +export type WorktreeLineageWarningCode = + | 'LINEAGE_PARENT_CONTEXT_MISSING' + | 'LINEAGE_PARENT_CONTEXT_CONFLICT' + | 'LINEAGE_PARENT_INSTANCE_STALE' + +export type WorktreeLineageWarning = { + code: WorktreeLineageWarningCode + message: string + details?: Record +} + +// ─── Diff line comments ────────────────────────────────────────────── +// Why: users leave review notes on specific lines of the modified side of +// a diff so they can be handed back to an AI agent (pasted into a terminal +// or used to bootstrap a new agent session). Stored on WorktreeMeta so the +// existing persistence layer writes them to orca-data.json automatically. +export type DiffCommentSource = 'diff' | 'markdown' +export type DiffReviewScope = 'unstaged' | 'staged' | 'branch' + +export type MobileDiffReviewFileState = { + key: string + filePath: string + oldPath?: string + scope: DiffReviewScope + lastOpenedAt?: number + lastSeenDiffIdentity?: string + reviewedAt?: number + reviewDiffIdentity?: string +} + +export type MobileDiffReviewState = { + version: 1 + updatedAt?: number + completedAt?: number + files: Record +} + +export type DiffComment = { + id: string + worktreeId: string + filePath: string + /** Undefined means a legacy diff note. */ + source?: DiffCommentSource + /** Exact text selected when creating a markdown note, when available. */ + selectedText?: string + /** Inclusive range start. Must be <= lineNumber when present. */ + startLine?: number + lineNumber: number + body: string + createdAt: number + updatedAt?: number + /** Set after the note has been handed to an agent. Edits clear it. */ + sentAt?: number + scope?: DiffReviewScope + oldPath?: string + diffIdentity?: string + // Reserved for future "comments on the original side" — always 'modified' in v1. + side: 'modified' +} + +// ─── Tab Group Layout ─────────────────────────────────────────────── +export type TabGroupSplitDirection = 'horizontal' | 'vertical' + +export type TabGroupLayoutNode = + | { type: 'leaf'; groupId: string } + | { + type: 'split' + direction: TabGroupSplitDirection + first: TabGroupLayoutNode + second: TabGroupLayoutNode + /** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */ + ratio?: number + } + +// ─── Unified Tab ──────────────────────────────────────────────────── +export type TabContentType = + | 'terminal' + | 'editor' + | 'diff' + | 'conflict-review' + | 'check-details' + | 'browser' + | 'simulator' + +export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'simulator' +export type CtrlTabOrderMode = 'mru' | 'sequential' + +export type Tab = { + id: string // UUID for terminals, filePath for editors (preserves current convention) + entityId: string // ID of the backing content (terminal tab ID, file path, browser workspace ID) + groupId: string + worktreeId: string + contentType: TabContentType + label: string // display title (auto-derived from PTY or filename) + generatedLabel?: string | null + /** Stable AI Vault conversation name, bound to its provider session identity. */ + aiVaultTitle?: AiVaultSessionTitle | null + quickCommandLabel?: string | null + customLabel: string | null + color: string | null + sortOrder: number + createdAt: number + isPreview?: boolean // preview tabs get replaced by next single-click open + isPinned?: boolean // pinned tabs survive "close others" + /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the + * native chat view as an overlay while the live terminal stays mounted + * underneath; `'terminal'` (the default for legacy/missing) shows the raw + * xterm. Optional so sessions persisted before this field hydrate cleanly. */ + viewMode?: 'terminal' | 'chat' +} + +export type TabGroup = { + id: string + worktreeId: string + activeTabId: string | null + tabOrder: string[] // canonical visual order of tab IDs + /** Per-group MRU stack (oldest → most-recent at the tail). Drives which tab + * becomes active when the current active tab closes: we pop back to the + * previously-active tab instead of jumping to a visual neighbor. Scoped to + * the group so split panes keep independent histories. Optional because + * sessions persisted before this field was added still hydrate cleanly — + * hydration seeds from activeTabId. */ + recentTabIds?: string[] +} + +// ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ +export type TerminalTab = { + id: string + ptyId: string | null + worktreeId: string + title: string + /** Stable fallback label for default-named terminals ("Terminal 1", etc.). + * Why: agent CLIs overwrite the live title via OSC updates, but Orca still + * needs the original terminal label for numbering and reset behavior. */ + defaultTitle?: string + /** Stable opt-in label derived from the first known agent prompt. */ + generatedTitle?: string | null + /** Stable AI Vault conversation name, bound to its provider session identity. */ + aiVaultTitle?: AiVaultSessionTitle | null + /** Stable label from the tab-bar Quick Command that created this terminal. */ + quickCommandLabel?: string | null + customTitle: string | null + color: string | null + /** Pinned tabs survive "close others"; host-persisted for remote servers. */ + isPinned?: boolean + /** Per-tab view preference (terminal xterm vs native chat); host-persisted so + * paired clients converge. Optional: older persisted tabs default to 'terminal'. */ + viewMode?: 'terminal' | 'chat' + sortOrder: number + createdAt: number + /** Bumped on shutdown so TerminalPane remounts with a fresh PTY. */ + generation?: number + /** Why: records the shell this tab was opened with (e.g. 'wsl.exe') so the + * PTY and tab icon stay stable even if the default shell setting changes + * later. Older persisted tabs may omit this field. */ + shellOverride?: string + /** Keeps an ephemeral host fallback out of the active project's runtime. */ + forceHostRuntime?: boolean + /** Why: explorer-created terminals can start below the workspace root while + * still belonging to that workspace for tab/session ownership. */ + startupCwd?: string + /** Why: the coding-harness agent Orca launched in this tab. Lets the tab bar + * show the provider icon immediately, before the agent emits its first hook + * event (a freshly-launched, idle agent reports no live status yet). Live + * hook status overrides this once the agent does anything. Plain terminals + * and manually-started agents omit it. */ + launchAgent?: TuiAgent + /** Host-owned agent-launch notices + their dismissal token for this terminal. + * The host is the sole owner; renderer/mobile mirror it and never recreate a + * dismissed notice. Absent when no agent launch produced a notice. */ + launchNotices?: PersistedLaunchNoticeState + /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a + * TerminalPane remount, the fresh PTY that results is caused by navigation, + * not by the user doing work. Without this flag the resulting + * `updateTabPtyId` call would call `bumpWorktreeActivity` and flip the + * sidebar's recency sort on every click — the reorder-on-click bug. The + * flag is set by `setActiveWorktree` and consumed by the activation-driven + * PTY lifecycle calls that follow, which then suppress activity bumps and + * `sortEpoch` increments. Split layouts use a numeric count because one tab + * can remount several panes. Never persisted — it is a transient handoff. */ + pendingActivationSpawn?: boolean | number +} + +export type BrowserHistoryEntry = { + url: string + normalizedUrl: string + title: string + lastVisitedAt: number + visitCount: number +} + +export type BrowserLoadError = { + code: number + description: string + validatedUrl: string +} + +export type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +export type BrowserCertificateProceedFailureReason = + | 'expired' + | 'changed' + | 'ineligible' + | 'missing' + | 'navigated' + +export type BrowserCertificateProceedResult = + | { ok: true } + | { ok: false; reason: BrowserCertificateProceedFailureReason } + +// Why: BrowserPage persists the active viewport preset so CDP emulation can be +// reapplied on reload/navigation without the user re-picking from the toolbar. +export type BrowserViewportPresetId = + | 'mobile-s' + | 'mobile-m' + | 'mobile-l' + | 'tablet' + | 'laptop' + | 'laptop-l' + | 'desktop' + +export type BrowserViewportOverride = { + width: number + height: number + deviceScaleFactor: number + mobile: boolean +} + +export type BrowserPage = { + id: string + workspaceId: string + worktreeId: string + url: string + title: string + loading: boolean + faviconUrl: string | null + canGoBack: boolean + canGoForward: boolean + loadError: BrowserLoadError | null + createdAt: number + // Why: remote-owned worktrees can still host client-local fallback browser + // pages until headless remote runtimes support real browser panes. + browserRuntimeEnvironmentId?: string | null + /** Active CDP viewport emulation preset. null = default (fill pane, no CDP override) */ + viewportPresetId?: BrowserViewportPresetId | null +} + +export type BrowserWorkspace = { + id: string + worktreeId: string + /** Stable display label for the outer Orca tab ("Browser 1", "Browser 2", …). + * Optional so sessions persisted before this field was added fall back + * gracefully to the URL-derived label in getBrowserTabLabel. */ + label?: string + // Why: each browser workspace binds to exactly one session profile at creation + // time. The profile determines which Electron partition (and thus which + // cookies/storage) the guest webview uses. Absent means the legacy shared + // partition, which keeps backward compat with workspaces persisted before + // session profiles existed. + sessionProfileId?: string | null + // Why: runtime-created tabs resolve profile partition in main. Persisting it + // keeps isolated storage stable when the renderer profile mirror is stale. + sessionPartition?: string | null + activePageId?: string | null + pageIds?: string[] + // Why: the active page owns real browser chrome state now, but the top-level + // Orca tab strip still renders one workspace entry. Mirror the active page's + // title/url/loading metadata here so existing workspace-level UI can stay + // stable while Phase 2 introduces nested browser pages. + url: string + title: string + loading: boolean + faviconUrl: string | null + canGoBack: boolean + canGoForward: boolean + loadError: BrowserLoadError | null + createdAt: number +} + +export type BrowserTab = BrowserWorkspace + +export type BrowserSessionProfileScope = 'default' | 'isolated' | 'imported' + +export type BrowserSessionUserAgentMode = 'clean' | 'native' + +export type BrowserSessionProfileCreateOptions = { + userAgentMode?: BrowserSessionUserAgentMode +} + +export type BrowserSessionProfileSource = { + browserFamily: + | 'chrome' + | 'chromium' + | 'arc' + | 'edge' + | 'firefox' + | 'safari' + | 'comet' + | 'helium' + | 'manual' + profileName?: string + importedAt: number +} + +export type BrowserSessionProfile = { + id: string + scope: BrowserSessionProfileScope + partition: string + label: string + source: BrowserSessionProfileSource | null + userAgentMode?: BrowserSessionUserAgentMode +} + +export type BrowserCookieImportSummary = { + totalCookies: number + importedCookies: number + skippedCookies: number + googleCookiesSkipped?: number + domains: string[] + warning?: { + code: 'restart-fallback-unavailable' + loadedCookies: number + failedCookies: number + } +} + +export type BrowserCookieImportResult = + | { ok: true; profileId: string; summary: BrowserCookieImportSummary } + | { ok: false; reason: string } + +export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' + +export type TerminalPaneLayoutNode = + | { + type: 'leaf' + leafId: string + } + | { + type: 'split' + direction: TerminalPaneSplitDirection + first: TerminalPaneLayoutNode + second: TerminalPaneLayoutNode + /** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */ + ratio?: number + } + +export type TerminalLayoutSnapshot = { + root: TerminalPaneLayoutNode | null + activeLeafId: string | null + expandedLeafId: string | null + /** Live PTY IDs per leaf for in-session remounts such as tab-group moves. + * Not used for app restart because PTYs are transient processes. */ + ptyIdsByLeafId?: Record + /** Serialized terminal buffers per leaf for scrollback restoration on restart. */ + buffersByLeafId?: Record + /** Durable scrollback snapshot refs per leaf; raw bytes live outside session JSON. */ + scrollbackRefsByLeafId?: Record + /** User-assigned pane titles, keyed by stable layout leaf UUID. + * Persisted alongside buffers via the existing session:set flow. */ + titlesByLeafId?: Record +} + +/** Minimal subset of OpenFile persisted across restarts. + * Only edit-mode files are saved — diffs, conflict reviews, and other + * transient views are reconstructed on demand from git state. */ +export type PersistedOpenFile = { + filePath: string + relativePath: string + worktreeId: string + language: string + isPreview?: boolean + runtimeEnvironmentId?: string | null + /** SSH target that owns an absolute path outside the worktree. */ + externalSshTargetId?: string + /** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */ + dirtyDraftContent?: string + /** Signature of the disk content the dirty draft is based on; lets restore + * re-derive a changed-on-disk conflict from ground truth. */ + lastKnownDiskSignature?: string + /** Why: a read-only tab (AI Vault View Log) must survive restart still + * read-only; persisted only when true so old sessions stay writable. */ + readOnly?: boolean + /** Opt-in streaming append for a read-only local log tab. */ + liveTail?: boolean +} + +export type WorkspaceSessionState = { + activeRepoId: string | null + /** Scope-aware active owner for folder workspaces. Legacy worktree UI still reads activeWorktreeId. */ + activeWorkspaceKey?: WorkspaceKey | null + activeWorkspaceExecutionHostId?: ExecutionHostId | null + activeWorktreeId: string | null + activeTabId: string | null + /** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */ + tabsByWorktree: Record + terminalLayoutsByTabId: Record + /** Worktree IDs that had at least one tab with a live PTY at shutdown. + * Used on startup to eagerly re-spawn PTY processes so the Active filter + * works immediately after restart. */ + activeWorktreeIdsOnShutdown?: string[] + /** Editor files that were open at shutdown, keyed by worktree ID. + * Only edit-mode files are persisted — diffs and conflict views are + * transient and not restored. */ + openFilesByWorktree?: Record + /** Per-worktree active editor file ID (filePath) at shutdown. */ + activeFileIdByWorktree?: Record + /** Per-file markdown preview front-matter visibility. Absent entry means hidden. */ + markdownFrontmatterVisible?: Record + /** Persisted browser workspaces, keyed by worktree ID. */ + browserTabsByWorktree?: Record + /** Persisted browser pages, keyed by workspace ID. */ + browserPagesByWorkspace?: Record + /** Per-worktree active browser workspace ID at shutdown. */ + activeBrowserTabIdByWorktree?: Record + /** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */ + activeTabTypeByWorktree?: Record + /** Global browser URL history for address bar autocomplete. */ + browserUrlHistory?: BrowserHistoryEntry[] + /** Per-worktree last-active terminal tab ID at shutdown. */ + activeTabIdByWorktree?: Record + /** Unified tab model — present when saved by a build that includes TabsSlice. + * Read-path checks for this first; falls back to legacy fields if absent. */ + unifiedTabs?: Record + /** Tab group model — present alongside unifiedTabs. */ + tabGroups?: Record + /** Persisted split layout tree per worktree. */ + tabGroupLayouts?: Record + /** Per-worktree focused group at shutdown. */ + activeGroupIdByWorktree?: Record + /** SSH target IDs that were connected at shutdown. Used on startup to + * auto-reconnect before attempting remote PTY reattach. */ + activeConnectionIdsAtShutdown?: string[] + /** Maps tab IDs to their remote relay PTY session IDs. Populated at + * shutdown from renderer state so remote PTYs can be reattached via + * the relay's pty.attach RPC on startup. */ + remoteSessionIdsByTabId?: Record + /** Per-worktree focus-recency timestamps used by the Cmd+J empty-query + * ordering. Separate from worktree.lastActivityAt (background signal) + * and worktreeNavHistory (Back/Forward stack). See + * docs/cmd-j-empty-query-ordering.md. Absent in sessions written by + * older builds — hydration tolerates missing/partial maps and the + * active worktree is seeded on first restore. */ + lastVisitedAtByWorktreeId?: Record + /** Worktrees whose repo-defined default terminal tabs have already been + * considered. Persisted so closing all tabs and re-opening the workspace + * does not recreate the template. */ + defaultTerminalTabsAppliedByWorktreeId?: Record + /** Provider-session resume records captured when workspaces sleep. */ + sleepingAgentSessionsByPaneKey?: Record + /** Host-issued process incarnation for each durable terminal surface. */ + terminalPtyIncarnationsByPaneKey?: Record + /** Monotonic host authority watermark for terminal membership in each repo. */ + terminalTopologyRevisionByRepoId?: Record + /** Legacy per-surface fences migrated into terminalTopologyRevisionByRepoId on load. */ + terminalSurfaceTombstonesByPaneKey?: Record< + string, + { + worktreeId: string + parentTabId: string + leafId: string + ptyId: string + incarnationId: string + retiredAt: number + } + > +} + +export type WorkspaceSessionPatch = Partial + +// ─── GitHub ────────────────────────────────────────────────────────── +export type PRState = 'open' | 'closed' | 'merged' | 'draft' +export type IssueState = 'open' | 'closed' +export type CheckStatus = 'pending' | 'success' | 'failure' | 'neutral' + +export type PRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' +export type PRReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' + +export type PRConflictSummary = { + baseRef: string + baseCommit: string + commitsBehind: number + files: string[] + localMergeState?: 'clean' +} + +// Why: host must survive renderer/RPC boundaries so Enterprise review actions +// cannot silently fall back to a same-named repository on github.com. +export type GitHubRepositoryIdentity = { owner: string; repo: string; host?: string } + +export type GitHubPRMergeMethod = 'merge' | 'squash' | 'rebase' + +export type GitHubPRMergeMethodSettings = { + defaultMethod: GitHubPRMergeMethod + allowedMethods: Record +} + +export type GitHubPRStackEntry = { + position: number + number: number + title: string + url: string + updatedAt?: string + state: PRState + checksStatus: CheckStatus + mergeable: PRMergeableState + reviewDecision?: PRReviewDecision | null + mergeStateStatus?: string | null + headRefName?: string + headSha?: string +} + +export type GitHubPRStack = { + number: number + position: number + size: number + baseRefName: string + baseSha?: string + entries?: GitHubPRStackEntry[] +} + +export type PRInfo = { + number: number + title: string + state: PRState + url: string + checksStatus: CheckStatus + updatedAt: string + mergeable: PRMergeableState + reviewDecision?: PRReviewDecision | null + autoMergeEnabled?: boolean + autoMergeAllowed?: boolean | null + mergeQueueRequired?: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings + mergeStateStatus?: string | null + /** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */ + stack?: GitHubPRStack + // Why: check-runs are keyed by the PR head commit, not the mutable branch name. + // Keeping the head SHA in cached PR metadata lets the checks panel poll the + // correct commit without re-querying GitHub or guessing from local branch refs. + headSha?: string + // Why: a merged branch-matched PR stays visible when the worktree head is one + // of the PR's own commits (behind update-branch/web commits). Cache staleness + // checks must honor that confirmation without re-querying GitHub. + confirmedContainedHeadOid?: string + // Why: the worktree HEAD OID this merged linked PR was confirmed to have + // diverged from (a definite not-contained probe). Head-scoped, not a bare + // boolean, so a PR-number-coalesced refresh broadcast cannot clear a sibling + // worktree whose own head is still on the PR's line of work. Clearing a + // durable linked PR requires this positive signal for that exact head, never + // the mere absence of a containment confirmation after a rate-limit/error. + headDivergedFromMergedPRAtOid?: string + /** Target branch name for PR-created worktree compare-base repair. */ + baseRefName?: string + /** PR head branch name. Lets linked-PR consumers detect that the worktree + * has switched to a different branch and the durable link is stale. */ + headRefName?: string + prRepo?: GitHubRepositoryIdentity + headRepo?: GitHubRepositoryIdentity + conflictSummary?: PRConflictSummary +} + +/** + * Discriminates a classified GitHub PR-refresh failure. The renderer maps these + * to stable, non-destructive empty-state copy; a `hard` subset (auth, permission, + * repo_unavailable, gh_unavailable) means the existing-review lookup is currently + * impossible and must hide the Create composer. + */ +export type PRRefreshErrorType = + | 'rate_limited' + | 'auth' + | 'network' + | 'permission' + | 'repo_unavailable' + | 'gh_unavailable' + | 'server_error' + | 'unknown' + +// Backward-compatible name used by outage-copy consumers added on main. +export type PRRefreshUpstreamErrorType = PRRefreshErrorType + +export type PRRefreshOutcome = + | { kind: 'found'; pr: PRInfo; fetchedAt: number } + | { kind: 'no-pr'; fetchedAt: number } + | { + kind: 'upstream-error' + errorType: PRRefreshErrorType + message: string + fetchedAt: number + // Unified retry schedule (see docs/reference/pr-panel-refresh-guidance.md). + // `nextAutoRetryAt`: earliest time main expects to auto-retry this key. + // `retryDisabledUntil`: earliest time a manual Retry / refreshPRNow is + // accepted (rate-limit gates only, never ordinary network/auth backoff). + nextAutoRetryAt?: number + retryDisabledUntil?: number + } + +export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' + +export type GitHubPRRefreshEnqueueResult = + | { kind: 'queued' } + | { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' } + | { kind: 'fallback' } + +export type GitHubPRRefreshAlias = { + cacheKey: string + repoId?: string + repoPath: string + branch: string + worktreeId?: string + connectionId?: string | null + executionHostId?: string | null + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: 'explicit' | 'pr-cache' | 'hosted-review' | null + // Why: request-time worktree HEAD. Merged branch-matched PRs are only visible + // for heads that belong to the PR, and refresh consumers need this snapshot to + // clear a durable linked PR once main confirms the head diverged. + currentHeadOid?: string | null +} + +export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { + repoKind: RepoKind + repoId: string + isBare?: boolean + isArchived?: boolean + connectionId?: string | null + executionHostId?: string | null + connectionState?: 'connected' | 'disconnected' | 'unknown' + cachedFetchedAt?: number | null + cachedHasPR?: boolean | null + cachedPRState?: PRState | null + cachedChecksStatus?: CheckStatus | null + cachedMergeable?: PRMergeableState | null + cachedMergeStateStatus?: string | null + localGitOptions?: { wslDistro?: string } +} + +export type GitHubPRRefreshSkippedReason = + | 'fresh' + | 'not-git' + | 'bare' + | 'archived' + | 'disconnected' + | 'remote' + | 'rate-limit' + | 'capacity' + +type GitHubPRRefreshEventBase = { + sequence: number + reason: GitHubPRRefreshReason + aliases: GitHubPRRefreshAlias[] + requestStartedAt?: number +} + +export type GitHubPRRefreshEvent = + | (GitHubPRRefreshEventBase & { + outcome: PRRefreshOutcome + status?: never + pausedUntil?: never + skippedReason?: never + }) + | (GitHubPRRefreshEventBase & { + status: 'queued' | 'in-flight' + outcome?: never + pausedUntil?: never + skippedReason?: never + }) + | (GitHubPRRefreshEventBase & { + status: 'paused' + pausedUntil: number + skippedReason: 'rate-limit' + outcome?: never + }) + | (GitHubPRRefreshEventBase & { + status: 'skipped' + skippedReason: GitHubPRRefreshSkippedReason + outcome?: never + pausedUntil?: never + }) + +export type PRCheckDetail = { + name: string + status: 'queued' | 'in_progress' | 'completed' + conclusion: + | 'success' + | 'failure' + | 'cancelled' + | 'timed_out' + | 'neutral' + | 'skipped' + | 'pending' + // Why: a check suite needing manual action (e.g. a workflow awaiting "Approve + // and run") has no check run and is absent from statusCheckRollup, yet blocks + // auto-merge (GitHub returns "unstable status"). Surface it as its own state. + | 'action_required' + | null + url: string | null + checkRunId?: number + workflowRunId?: number + // Why: the GitLab job trace API is addressed by numeric job id only, so the + // Checks panel cannot load a job log without carrying it on the row. + gitlabJobId?: number +} + +export type PRCheckAnnotation = { + path: string | null + startLine: number | null + endLine: number | null + annotationLevel: string | null + title: string | null + message: string + rawDetails: string | null +} + +export type PRCheckStep = { + name: string + status: string | null + conclusion: string | null + startedAt: string | null + completedAt: string | null +} + +export type PRCheckJob = { + id: number | null + name: string + status: string | null + conclusion: string | null + startedAt: string | null + completedAt: string | null + url: string | null + logTail: string | null + steps: PRCheckStep[] +} + +export type PRCheckRunDetails = { + name: string + status: PRCheckDetail['status'] | (string & {}) | null + conclusion: PRCheckDetail['conclusion'] | (string & {}) | null + url: string | null + detailsUrl: string | null + startedAt: string | null + completedAt: string | null + title: string | null + summary: string | null + text: string | null + annotations: PRCheckAnnotation[] + jobs: PRCheckJob[] +} + +export type GitHubRerunPRChecksResult = { ok: true; count: number } | { ok: false; error: string } + +export type GitHubReactionContent = + | '+1' + | '-1' + | 'laugh' + | 'confused' + | 'heart' + | 'hooray' + | 'rocket' + | 'eyes' + +export type GitHubReaction = { + content: GitHubReactionContent + count: number + viewerHasReacted?: boolean +} + +export type PRComment = { + id: number + author: string + authorAvatarUrl: string + body: string + createdAt: string + url: string + reactions?: GitHubReaction[] + /** GraphQL node ID for GitHub comments that support reaction mutations. */ + reactionSubjectId?: string + /** File path for inline review comments (absent for top-level conversation comments). */ + path?: string + /** GraphQL node ID of the review thread — present only for inline review comments. + * Used to resolve/unresolve the thread via GitHub's GraphQL API. */ + threadId?: string + /** Whether the review thread has been resolved. Only meaningful when threadId is set. */ + isResolved?: boolean + /** True when GitHub no longer maps the thread to the current diff. */ + isOutdated?: boolean + /** End line of the review annotation (1-based). */ + line?: number + /** Start line of the review annotation range (1-based). Absent for single-line comments. */ + startLine?: number + /** True when GitHub identifies the author as a bot (REST `user.type === 'Bot'` or + * GraphQL `__typename === 'Bot'`). Preferred over login-string heuristics because + * third-party review bots (e.g. qodo-ai-reviewer, coderabbitai) don't follow a + * predictable naming convention. Absent when the data source can't report it + * (non-GitHub fallbacks via `gh pr view`). */ + isBot?: boolean +} + +export type GitHubIssueTimelineTarget = { + type: 'issue' | 'pr' + number: number + title: string + url: string + repository?: string +} + +export type GitHubIssueTimelineItem = { + id: string + event: + | 'assigned' + | 'unassigned' + | 'mentioned' + | 'cross-referenced' + | 'closed' + | 'reopened' + | 'moved_columns_in_project' + actor: string + actorAvatarUrl: string + createdAt: string + assignee?: string + source?: GitHubIssueTimelineTarget + closer?: GitHubIssueTimelineTarget + stateReason?: string | null + previousColumnName?: string | null + columnName?: string | null + projectName?: string | null +} + +export type GitHubCommentResult = { ok: true; comment: PRComment } | { ok: false; error: string } + +export type IssueInfo = { + number: number + title: string + state: IssueState + url: string + labels: string[] + /** Full markdown body when fetched through the single-issue endpoint. */ + description?: string +} + +export type GitHubViewer = { + login: string + email: string | null +} + +export type GitHubAssignableUser = { + login: string + name: string | null + avatarUrl: string +} + +export type ProviderCheckSummary = { + state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' + total: number + passed: number + failed: number + pending: number + neutral: number +} + +export type GitHubPRReviewSummary = { + login: string + state?: string | null + avatarUrl?: string | null +} + +export type GitHubPRFileViewedState = 'DISMISSED' | 'VIEWED' | 'UNVIEWED' + +export type GitHubWorkItem = { + id: string + type: 'issue' | 'pr' + number: number + title: string + state: 'open' | 'closed' | 'merged' | 'draft' + url: string + labels: string[] + updatedAt: string + author: string | null + // Why: GHE user logins don't exist on github.com, so the github.com/{login}.png + // fallback 404s. Carry the API-provided avatar_url so github.com + Enterprise + // both render; absent on the gh-pr-view path (gh omits avatar), then the UI + // falls back to the login URL and finally an initials placeholder. See #8784. + authorAvatarUrl?: string + branchName?: string + baseRefName?: string + // Why: PR checks are keyed by head commit; carrying this lets task rows use + // the cached check-runs endpoint instead of one `gh pr checks` call per row. + headSha?: string + prRepo?: GitHubRepositoryIdentity + additions?: number + deletions?: number + changedFiles?: number + reviewDecision?: PRReviewDecision | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + assignees?: GitHubAssignableUser[] + checksSummary?: ProviderCheckSummary + mergeable?: PRMergeableState + autoMergeEnabled?: boolean + autoMergeAllowed?: boolean | null + mergeQueueRequired?: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings + mergeStateStatus?: string | null + maintainerCanModify?: boolean + // Why: true when a PR's head lives on a fork (headRepositoryOwner !== selected repo owner). + // The Start-from picker passes this to resolvePrBase so fork heads use + // refs/pull//head for creation and a separate PR-head push target. + isCrossRepository?: boolean + /** Why: required because the cross-repo view merges items from every selected + * repo — the table row's repo pill and the "open in browser" fallback need + * to know which repo an item came from. Stamped by the renderer fetcher + * (`fetchWorkItems`) and by optimistic stubs on the new-issue path. */ + repoId: string +} + +export type GitHubPRFile = { + path: string + oldPath?: string + status: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged' + additions: number + deletions: number + /** GitHub marks files above its diff size limit as binary-like; we skip content fetches for these. */ + isBinary: boolean + /** Modified-side line numbers that GitHub accepts for inline review comments. */ + reviewCommentLineNumbers?: number[] + /** GitHub's per-viewer review state. DISMISSED means new changes arrived after the file was viewed. */ + viewerViewedState?: GitHubPRFileViewedState +} + +export type GitHubPRFileContents = { + original: string + modified: string + originalIsBinary: boolean + modifiedIsBinary: boolean + originalTooLarge?: boolean + modifiedTooLarge?: boolean +} + +export type GitHubPRReviewCommentInput = { + repoPath: string + prRepo?: GitHubRepositoryIdentity | null + prNumber: number + commitId: string + path: string + line: number + startLine?: number + body: string +} + +export type GitHubWorkItemDetails = { + // Why: main-process doesn't know Orca's Repo.id, so this inner item omits + // repoId. The renderer stamps it when routing the details through the store. + item: Omit + body: string + comments: PRComment[] + /** Issue-only provider activity such as assignment, references, project moves, and state changes. */ + timelineItems?: GitHubIssueTimelineItem[] + /** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */ + headSha?: string + baseSha?: string + /** GraphQL node ID required by GitHub's file-viewed mutations. Only set for PRs. */ + pullRequestId?: string + checks?: PRCheckDetail[] + files?: GitHubPRFile[] + /** Only set for PRs. True when the file fetch failed (rate limit, auth, + * unresolved remote) rather than the PR genuinely having no changed files. */ + filesUnavailable?: boolean + participants?: GitHubAssignableUser[] + /** Logins of current assignees. Only set for issues. */ + assignees?: string[] +} + +// ─── Linear ───────────────────────────────────────────────────────── +export type LinearViewer = { + displayName: string + email: string | null + organizationId?: string + organizationName: string + organizationUrlKey?: string +} + +export type LinearWorkspace = LinearViewer & { + id: string + organizationId: string + isLegacy?: true + credentialRevision?: number +} + +export type LinearWorkspaceSelection = (string & {}) | 'all' +export type LinearWorkspaceSelector = LinearWorkspaceSelection | undefined +export type LinearConcreteWorkspaceId = string + +export type LinearWorkspaceError = { + workspaceId: string + workspaceName?: string + type: 'auth' | 'rate_limited' | 'network' | 'unknown' + message: string +} + +export type LinearCollectionResult = { + items: T[] + errors?: LinearWorkspaceError[] + hasMore?: boolean +} + +export type LinearConnectionStatus = { + connected: boolean + viewer: LinearViewer | null + workspaces?: LinearWorkspace[] + activeWorkspaceId?: string | null + selectedWorkspaceId?: LinearWorkspaceSelection | null + // Set when a stored token file exists but could not be decrypted, so the + // UI can explain reads failing while the connection still looks saved. + credentialError?: string +} + +export type LinearIssue = { + id: string + workspaceId?: string + workspaceName?: string + identifier: string + title: string + branchName?: string + description?: string + url: string + state: { + name: string + type: string + color: string + } + team: { + id: string + name: string + key: string + } + project?: LinearProjectSummary + subIssues?: LinearIssueChildSummary[] + labels: string[] + labelIds: string[] + assignee?: { + id: string + displayName: string + avatarUrl?: string + } + estimate?: number | null + priority: number + dueDate?: string | null + updatedAt: string +} + +export type LinearProjectSummary = { + id: string + slugId?: string + workspaceId?: string + workspaceName?: string + name: string + url?: string + color?: string + icon?: string + description?: string + content?: string + status?: LinearProjectStatusSummary + health?: string | null + priority?: number | null + priorityLabel?: string | null + lead?: LinearProjectMemberSummary + members?: LinearProjectMemberSummary[] + teams?: { + id: string + name: string + key?: string + }[] + labels?: { + id: string + name: string + color?: string + }[] + startDate?: string | null + targetDate?: string | null + createdAt?: string + updatedAt?: string + completedAt?: string | null + canceledAt?: string | null + startedAt?: string | null + progress?: number | null + scope?: number | null + issueCount?: number + completedIssueCount?: number +} + +export type LinearProjectStatusSummary = { + id: string + name: string + type?: string + color?: string +} + +export type LinearProjectMemberSummary = { + id: string + displayName: string + avatarUrl?: string +} + +export type LinearProjectMilestoneSummary = { + id: string + name: string + status?: string + targetDate?: string | null + progress?: number | null +} + +export type LinearProjectResourceSummary = { + id: string + title: string + url: string + type?: string +} + +export type LinearProjectUpdateSummary = { + id: string + body?: string + health?: string | null + url?: string + createdAt?: string + updatedAt?: string + user?: LinearProjectMemberSummary +} + +export type LinearProjectDetail = LinearProjectSummary & { + milestones?: LinearProjectMilestoneSummary[] + resources?: LinearProjectResourceSummary[] + latestUpdate?: LinearProjectUpdateSummary +} + +export type LinearCustomViewModel = 'issue' | 'project' + +export type LinearCustomViewSummary = { + id: string + workspaceId?: string + workspaceName?: string + name: string + description?: string + model: LinearCustomViewModel + url?: string + color?: string + icon?: string + shared?: boolean + team?: { + id: string + name?: string + key?: string + } + owner?: LinearProjectMemberSummary + creator?: LinearProjectMemberSummary + createdAt?: string + updatedAt?: string +} + +export type LinearIssueChildSummary = { + id: string + identifier: string + title: string + url: string +} + +export type LinearComment = { + id: string + body: string + createdAt: string + user?: { + displayName: string + avatarUrl?: string + } +} + +// ─── Issue Mutations ──────────────────────────────────────────────── + +export type GitHubCreateIssueFields = { + labels?: string[] + assignees?: string[] +} + +export type GitHubCreateIssueResult = + | { ok: true; number: number; url: string; bodySaveWarning?: string } + | { ok: false; error: string } + +export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' + +export type GitHubIssueUpdate = { + state?: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number + title?: string + // Why: body writes use the REST issue endpoint instead of `gh issue edit` + // because that command does not consistently cover every body-edit case the + // dialog needs. + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] +} + +export type GitHubPullRequestStateUpdate = { + state: 'open' | 'closed' +} + +export type LinearIssueUpdate = { + stateId?: string + title?: string + description?: string + assigneeId?: string | null + estimate?: number | null + priority?: number + dueDate?: string | null + labelIds?: string[] + projectId?: string | null + parentId?: string | null +} + +export type ClassifiedError = { + type: + | 'permission_denied' + | 'not_found' + | 'issues_disabled' + | 'validation_error' + | 'rate_limited' + | 'network_error' + | 'unknown' + message: string +} + +// Why: declared here as a shared shape so IPC return envelopes and renderer +// slices can reference the same structural type without importing from main. +// Aliased as `OwnerRepo` in `src/main/github/gh-utils.ts` so main call sites +// can continue using the short local name. +export type GitHubOwnerRepo = GitHubRepositoryIdentity + +// Why: GitLab-specific types live in `./gitlab-types` so they can grow +// independently from the central types file (which is touched by every +// upstream feature). Re-exported here so existing call sites +// (`from '../shared/types'`) keep working without changes. +export type { + GitLabAssignableUser, + GitLabAuthDiagnostic, + GitLabCommentResult, + GitLabDiscussionResolveResult, + GitLabIssueInfo, + GitLabIssueState, + GitLabIssueUpdate, + GitLabJobTraceResult, + GitLabRateLimitBucket, + GitLabRateLimitSnapshot, + GitLabMRApprovalRule, + GitLabMRApprovalState, + GitLabMRFile, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, + GitLabMRUpdate, + GitLabPagedResult, + GitLabPipelineJob, + GitLabProjectRef, + GitLabProjectSettings, + GitLabRetryJobResult, + GitLabReaction, + GitLabTodo, + GitLabTodoTargetType, + GitLabViewer, + GitLabWorkItem, + GitLabWorkItemDetails, + GetGitLabRateLimitResult, + ListMergeRequestsResult, + MRCheckDetail, + MRComment, + MRInfo, + MRListState, + MRMergeableState, + MRState +} from './gitlab-types' + +export type { + JiraAuthType, + JiraComment, + JiraConnectArgs, + JiraConnectionStatus, + JiraCreateField, + JiraCreateFieldAllowedValue, + JiraCreateIssueArgs, + JiraCreateIssueResult, + JiraIssue, + JiraIssueFilter, + JiraIssueType, + JiraIssueUpdate, + JiraMutationResult, + JiraPriority, + JiraProject, + JiraProjectStatusOrder, + JiraSite, + JiraSiteSelection, + JiraStatus, + JiraTransition, + JiraUser, + JiraViewer +} from './jira-types' + +/** + * GitHub API rate-limit buckets surfaced in the TaskPage header so users can + * see remaining budget before they hit the wall. `core` = REST (5000/hr), + * `search` = Search API (30/min — hit by countWorkItems), `graphql` = + * GraphQL (5000 points/hr — hit by project-view + discovery). All three are + * the buckets this app actually stresses; other buckets (e.g. code_search) + * are not surfaced because we don't touch them. + */ +export type GitHubRateLimitBucket = { + remaining: number + limit: number + /** Unix epoch seconds when the window resets. */ + resetAt: number +} + +export type GitHubRateLimitSnapshot = { + core: GitHubRateLimitBucket + search: GitHubRateLimitBucket + graphql: GitHubRateLimitBucket + /** Unix epoch ms the snapshot was produced (for "fetched Xs ago" copy). */ + fetchedAt: number +} + +export type GetRateLimitResult = + | { ok: true; snapshot: GitHubRateLimitSnapshot } + | { ok: false; error: string } + +/** + * Envelope for `gh:listWorkItems`. Carries resolved issue/PR sources so the + * renderer can render the "Issues from owner/repo" indicator without an + * extra IPC round-trip, and per-source classified errors so the UI can show + * a retryable banner when (e.g.) a private upstream 403s. + * + * Why piggyback instead of adding `gh:resolveWorkItemSources`: the renderer + * already round-trips this endpoint on every Tasks refresh, and the source + * data is a 2-field-per-side metadata add — cheaper than another IPC call. + * + * Invariant: `items` always contains whatever succeeded; `errors.issues` indicates + * the issues-side fetch failed, but any PR-side items that succeeded are still + * present in `items`. Consumers should render `items` alongside the error banner. + */ +export type ListWorkItemsResult = { + items: T[] + sources: { + issues: GitHubOwnerRepo | null + prs: GitHubOwnerRepo | null + /** Raw `origin` remote resolved for this repo, independent of the + * user's preference. Required-nullable so the renderer can compare raw + * remote candidates without inferring origin from the effective PR + * source. */ + originCandidate: GitHubOwnerRepo | null + /** Raw `upstream` remote resolved for this repo, independent of the + * user's preference. Present so the renderer's issue-source selector + * can always decide whether to render (upstream exists & differs from + * origin) and show both slugs in its tooltips, even when the user has + * picked 'origin' and `sources.issues` has collapsed onto origin. */ + upstreamCandidate: GitHubOwnerRepo | null + } + errors?: { + issues?: ClassifiedError + prs?: ClassifiedError + } + /** True when the user's per-repo preference was `'upstream'` but no upstream + * remote is configured, so the resolver fell back to origin. Renderer uses + * this to surface a one-time-per-session toast. Omitted when absent so + * existing consumers and test fixtures don't care about it. + * Typed as `?: true` (not `?: boolean`) to encode the invariant "present + * iff fell-back" — an explicit `false` write would be a bug, so make it a + * compile error. */ + issueSourceFellBack?: true +} + +export type LinearWorkflowState = { + id: string + name: string + type: string + color: string + position: number +} + +export type LinearLabel = { + id: string + name: string + color: string +} + +export type LinearMember = { + id: string + displayName: string + name?: string + email?: string + avatarUrl?: string +} + +export type LinearTeam = { + id: string + workspaceId?: string + workspaceName?: string + name: string + key: string + url?: string +} + +// ─── Hooks (orca.yaml) ────────────────────────────────────────────── +export type OrcaHooks = { + scripts: { + setup?: string // Runs after worktree is created + archive?: string // Runs before worktree is archived + } + issueCommand?: string // Shared default command for linked GitHub issues + defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree + environmentRecipes?: OrcaVmRecipe[] // Project-scoped per-workspace environment recipes + environmentRecipeDiagnostics?: OrcaVmRecipeDiagnostic[] // Non-fatal validation issues from environmentRecipes + worktree?: OrcaWorktreeDefaults // Project-scoped defaults applied when a worktree is created +} + +export type OrcaWorktreeDefaults = { + // Why: shared (symlinked) rather than copied — large rebuildable dirs like + // node_modules should be one install serving every worktree. + sharedDirectories?: string[] +} + +export type OrcaDefaultTabTemplate = { + title?: string + color?: string + command?: string +} + +export type OrcaVmRecipe = { + id: string + name: string + create: string + description?: string + suspend?: string + resume?: string + destroy?: string + destroyDisabled?: boolean +} + +export type OrcaVmRecipeDiagnostic = { + index: number + field?: string + message: string +} + +export type RepoHookSettings = { + // Why: persisted data may still include the old mode field from the earlier + // hook UI. Keep it in the shape so existing local state reads without a migration. + mode: 'auto' | 'override' + setupRunPolicy?: SetupRunPolicy + setupAgentStartupPolicy?: SetupAgentStartupPolicy + commandSourcePolicy?: HookCommandSourcePolicy + scripts: { + setup: string + archive: string + } +} + +export type WorktreeSetupLaunch = { + runnerScriptPath: string + envVars: Record + shell?: SetupRunnerShell + command?: string + waitForAgentStartup?: boolean +} + +export type WorktreeStartupLaunch = { + command: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + startupCommandDelivery?: StartupCommandDelivery + // agent_kind + used_custom_agent are host-authoritative on a resolved launch + // (the host overwrites them before spawn from the validated snapshot/receipt); + // launch_source/request_kind are surface-owned. Both are optional: host-resolved + // sites omit agent_kind, legacy non-resolver launches still thread it. + telemetry?: { + agent_kind?: AgentKind + launch_source: LaunchSource + request_kind: RequestKind + used_custom_agent?: boolean + } +} + +export type WorktreeDefaultTabsLaunch = { + tabs: OrcaDefaultTabTemplate[] + runCommands: boolean +} + +export type WorktreeCreateTimingPhase = { + phase: string + startedAtMs: number + durationMs: number +} + +export type WorktreeCreateTiming = { + totalDurationMs: number + phases: WorktreeCreateTimingPhase[] +} + +export type CreateSparseCheckoutRequest = { + directories: string[] + /** Set when the directories came from a saved preset and the user did not + * modify them — recorded on WorktreeMeta so the worktree can show "from + * preset X" later. Cleared if the user edited the textarea. */ + presetId?: string +} + +/** A reusable per-repo sparse directory list. Saved by the user from the + * composer; surfaced again the next time they create a worktree in the same + * repo. The MVP scope (no preset) is `presetId === undefined`. */ +export type SparsePreset = { + id: string + repoId: string + name: string + directories: string[] + createdAt: number + updatedAt: number +} + +export type CreateWorktreeArgs = { + repoId: string + name: string + /** Optional user-facing label to persist separately from the git-safe + * branch/path seed. Used when a workspace is created from a GitHub or + * Linear artifact whose title should remain readable in the sidebar. */ + displayName?: string + baseBranch?: string + /** Source Control compare target when it differs from the checkout start point. */ + compareBaseRef?: string + /** Optional git branch to create, separate from the filesystem-safe worktree + * name. Used when creating from an existing branch whose local branch name + * legitimately contains `/` while the worktree directory must not. */ + branchNameOverride?: string + setupDecision?: SetupDecision + sparseCheckout?: CreateSparseCheckoutRequest + linkedIssue?: number + linkedPR?: number + linkedLinearIssue?: string + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + linkedGitLabIssue?: number + linkedGitLabMR?: number + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + pushTarget?: GitPushTarget + workspaceStatus?: WorkspaceStatus + manualOrder?: number + /** Parent workspace for in-app creates launched from a folder workspace. */ + parentWorkspace?: WorkspaceKey + /** Agent selected in the create surface. Omitted for blank-shell creates. */ + createdWithAgent?: TuiAgent + /** Set when the renderer knows this auto-generated branch should be renamed + * from the first agent message. */ + pendingFirstAgentMessageRename?: boolean + /** Telemetry-only: which UI surface initiated this create. Threaded from + * the renderer entry point so main can emit `workspace_created` with the + * correct `source`. `unknown` is a valid wire value — an unrecognized + * surface emits `source: 'unknown'` rather than dropping the event, so + * dashboards surface enum-coverage gaps as a slice rather than as + * missing data. Optional on the type so older renderer code paths that + * pre-date this prop default to `unknown` at the IPC boundary instead + * of failing typecheck. */ + telemetrySource?: WorkspaceSource + /** Optional startup command for callers that want the backend to spawn the + * first terminal as soon as the worktree is registered. */ + startup?: WorktreeStartupLaunch + /** Host-resolved agent launch for a transactional two-stage create. When + * present the host owns resolution and IGNORES createdWithAgent/startup for + * the agent terminal — the same request shape carried by pty:spawn, + * terminal.create, and session.tabs.createTerminal (one launch contract + * across every surface). */ + agentLaunch?: AgentLaunchSpawnRequest + /** Surface-owned `agent_started` fields for a host-emitted interactive create. + * Sent only for interactive agentLaunch creates; the host derives agent_kind + + * used_custom_agent from the resolved receipt and never accepts them here. + * Omission means the host emits nothing (background/automation creates). */ + agentLaunchTelemetry?: { launch_source: LaunchSource; request_kind: RequestKind } + /** Correlates `createWorktree:progress` events back to a specific pending + * creation in the renderer, so concurrent background creates each drive + * their own status surface. Omitted by synchronous callers. */ + creationId?: string + /** Authorizes the host to mint system-owned automation provenance. */ + automationProvenanceRequest?: AutomationWorkspaceProvenanceRequest +} + +export type CreatedWorktreeResult = { + // Discriminates the created arm from a pre-create rejection. Optional so the + // many producers that build a created result need not set it explicitly. + created?: true + worktree: Worktree & { + parentWorktreeId?: string | null + childWorktreeIds?: string[] + lineage?: WorktreeLineage | null + workspaceLineage?: WorkspaceLineage | null + git?: GitWorktreeInfo + } + lineage?: WorktreeLineage | null + workspaceLineage?: WorkspaceLineage | null + warnings?: WorktreeLineageWarning[] + setup?: WorktreeSetupLaunch + setupReceipt?: { + requested: 'run' | 'skip' | 'inherit' + hookFound: boolean + startupPolicy: 'start-immediately' | 'wait-for-setup' + state: 'running' | 'skipped' | 'not_configured' | 'spawn_failed' + terminalHandle?: string + } + defaultTabs?: WorktreeDefaultTabsLaunch + warning?: string + baseFallback?: WorktreeCreateBaseFallback + initialBaseStatus?: WorktreeBaseStatusEvent + localBaseRefRefresh?: LocalBaseRefRefreshResult + localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion + startupTerminal?: { + spawned: boolean + handle?: string + tabId?: string + paneKey?: string | null + ptyId?: string | null + surface?: 'visible' | 'background' + } + timing?: WorktreeCreateTiming + // Present only when the create requested an agent (two-stage launch). A + // post-create resolution/spawn failure is an RPC success carrying the created + // worktree plus `status: 'failed'`, never a rejected request that loses the id. + agentLaunchResult?: + | { status: 'launched'; receipt: AgentLaunchReceipt } + | { status: 'failed'; failure: PersistedAgentLaunchFailure } +} + +export type WorktreeCreateBaseFallback = { + requestedRef: string + localRef: string +} +/** Pre-create agent-launch rejection: validation failed before any git side + * effect, so NO worktree exists. Carried in-band (never a thrown RPC error, + * which serializes lossily and drops the typed hints) so every transport can + * keep the composer open with client-safe recovery hints. */ +export type WorktreeAgentLaunchRejection = + // Transient (not persisted): no worktree was created, so there is no owner + // record to hold the failure. Mirrors terminal.create's pre-spawn outcome. + | { status: 'failed'; failure: AgentLaunchFailure } + | { status: 'rejected'; requestError: AgentLaunchRequestError } + +export type NotCreatedWorktreeResult = { + created: false + agentLaunchResult: WorktreeAgentLaunchRejection +} + +/** Result of a worktree create request: either the created worktree (optionally + * carrying a post-create launch result) or a pre-create rejection that created + * nothing. The rejection arm only occurs on the runtime/host launch path. */ +export type CreateWorktreeResult = CreatedWorktreeResult | NotCreatedWorktreeResult + +export type PreservedWorktreeBranch = { + branchName: string + head?: string +} + +export type RemoveWorktreeResult = { + preservedBranch?: PreservedWorktreeBranch +} + +export type ForceDeleteWorktreeBranchResult = { + deleted: true +} + +export type LocalBaseRefRefreshResult = { + status: 'updated' | 'skipped_dirty_worktree' | 'skipped_not_fast_forward' | 'skipped_error' + baseRef: string + localBranch: string + ownerWorktreePath?: string +} + +export type LocalBaseRefUpdateSuggestion = { + baseRef: string + localBranch: string + behind: number +} + +export type WorktreeBaseStatusKind = 'checking' | 'current' | 'drift' | 'base_changed' | 'unknown' + +export type WorktreeBaseStatusEvent = { + repoId: string + worktreeId: string + status: WorktreeBaseStatusKind + base: string + /** Configured remote name parsed from `base` (longest-prefix match). Absent + * when classification skipped optimistic reconcile (e.g. legacy fallback). */ + remote?: string + behind?: number + recentSubjects?: string[] +} + +export type WorktreeRemoteBranchConflictEvent = { + repoId: string + worktreeId: string + remote: string + branchName: string +} + +// ─── Updater ───────────────────────────────────────────────────────── + +// Why: the release object sent to the renderer omits `version` (redundant +// with the top-level UpdateStatus.version) to keep one source of truth. +export type ChangelogRelease = { + title: string + description: string + mediaUrl?: string + releaseNotesUrl: string +} + +export type ChangelogData = { + release: ChangelogRelease + releasesBehind: number | null +} + +export type UpdateCheckOptions = { + includePrerelease?: boolean + includePerfPrerelease?: boolean + localBuild?: boolean + /** Dev channel switching; `targetTag` pins an exact build, including older ones. */ + channel?: ReleaseChannel + targetTag?: string +} + +/** Non-release origins for an update. Derived from the dev-channel list so a new + * channel with its own repo cannot be reported as an ordinary release. */ +export type UpdateSource = 'local' | DedicatedRepoChannel + +/** Root-package Linux install formats whose update installs need privilege escalation. */ +export type LinuxRootPackageType = 'deb' | 'rpm' + +export type LinuxPackageInstallFailureReason = + | 'authentication-agent-unavailable' + | 'authentication-denied' + | 'package-install-failed' + +// Why: the renderer must not infer "no polkit agent" from copy alone — main classifies and the card branches on this discriminant. +export type LinuxPackageInstallRecovery = { + kind: 'linux-package-install' + packageType: LinuxRootPackageType + reason: LinuxPackageInstallFailureReason + version: string +} + +/** Why: only these two mean no safe command exists here; every other failure clears recovery entirely. */ +export type LinuxPackageCommandUnavailableReason = 'no-sudo' | 'no-package-manager' + +export type LinuxPackageInstallInstructions = + | { ok: true; command: string; packageFileName: string } + | { ok: false; reason: LinuxPackageCommandUnavailableReason; message: string } + +export type UpdateStatus = ( + | { state: 'idle' } + | { state: 'checking'; userInitiated?: boolean } + | { + state: 'available' + version: string + activeNudgeId?: string + // Why: releaseUrl is not currently populated by the update-available handler + // (it always sends undefined). Kept on the type for the Settings page's + // release-notes link fallback and for potential future use if the main + // process starts extracting release URLs from electron-updater metadata. + releaseUrl?: string + // Why: changelog is always explicitly set by the main process — null means + // the fetch failed or the version wasn't in the JSON (simple mode), and a + // populated object means rich mode. Using `| null` (not `?`) avoids a + // three-state ambiguity (undefined vs null vs present) and makes exhaustive + // checks straightforward. + changelog: ChangelogData | null + } + | { state: 'not-available'; userInitiated?: boolean } + | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } + | { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string } + | { + state: 'error' + message: string + userInitiated?: boolean + activeNudgeId?: string + recovery?: LinuxPackageInstallRecovery + } +) & { source?: UpdateSource } + +export type ReleaseBuildListResult = + | { ok: true; channel: ReleaseChannel; builds: ReleaseBuild[] } + | { ok: false; channel: ReleaseChannel; message: string } + +// ─── Settings ──────────────────────────────────────────────────────── +export type NotificationSettings = { + enabled: boolean + agentTaskComplete: boolean + terminalBell: boolean + suppressWhenFocused: boolean + customSoundId: + | 'system' + | 'two-tone' + | 'bong' + | 'thump' + | 'blip' + | 'sonar' + | 'blop' + | 'ding' + | 'clack' + | 'beep' + | 'custom' + customSoundPath: string | null + customSoundVolume: number +} + +export type CodexManagedAccount = { + id: string + email: string + managedHomePath: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxHomePath?: string | null + providerAccountId?: string | null + workspaceLabel?: string | null + workspaceAccountId?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type CodexManagedAccountSummary = { + id: string + email: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null + providerAccountId?: string | null + workspaceLabel?: string | null + workspaceAccountId?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +/** Live, read-only identity of the user's real ~/.codex used by the + * system-default (activeAccountId:null) Codex account. Orca reads this to + * display and attribute the system default; it never writes ~/.codex. */ +export type CodexSystemDefaultIdentity = { + /** True when ~/.codex/auth.json exists (signed in via a token file). */ + hasAuth: boolean + /** 'oauth' = ChatGPT sign-in with an id token (has ChatGPT usage); + * 'api-key' = env-key/custom provider (no ChatGPT usage); + * 'none' = signed out or identity could not be resolved. */ + authKind: 'oauth' | 'api-key' | 'none' + email: string | null + providerAccountId: string | null + workspaceLabel: string | null +} + +export type CodexRateLimitAccountsState = { + accounts: CodexManagedAccountSummary[] + activeAccountId: string | null + activeAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection + /** Resolved identity of the host system-default (real ~/.codex) account. + * Omitted for runtimes where it is not resolved (e.g. per-distro WSL). */ + systemDefault?: CodexSystemDefaultIdentity +} + +export type CodexManagedAccountRuntimeSelection = { + host: string | null + wsl: Record +} + +export type ClaudeManagedAccount = { + id: string + email: string + managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null + authMethod: 'subscription-oauth' | 'unknown' + organizationUuid?: string | null + organizationName?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type ClaudeManagedAccountSummary = { + id: string + email: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + authMethod: 'subscription-oauth' | 'unknown' + organizationUuid?: string | null + organizationName?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type ClaudeRateLimitAccountsState = { + accounts: ClaudeManagedAccountSummary[] + activeAccountId: string | null + activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection +} + +export type ClaudeManagedAccountRuntimeSelection = { + host: string | null + wsl: Record +} + +/** The closed set of built-in AI coding agents Orca knows how to launch. Static behavior + * registries (launch config, display names, telemetry kind, permissions, mobile parity) + * are keyed by this union only. Extend it as new built-in agents are added. */ +export type BuiltInTuiAgent = + | 'claude' // Claude Code + | 'claude-agent-teams' // Claude Code Agent Teams via Orca native panes + | 'openclaude' // OpenClaude + | 'codex' // OpenAI Codex + | 'autohand' // Autohand Code CLI + | 'opencode' // OpenCode + | 'mimo-code' + | 'pi' // Pi (pi.dev) + | 'omp' // OMP (omp.sh) + | 'gemini' // Gemini CLI + | 'antigravity' // Google Antigravity CLI + | 'aider' // Aider + | 'goose' // Goose + | 'amp' // Amp + | 'kilo' // Kilocode + | 'kiro' // Kiro + | 'crush' // Charm/Crush + | 'aug' // Augment/Auggie + | 'cline' // Cline + | 'codebuff' // Codebuff + | 'command-code' // Command Code + | 'continue' // Continue + | 'cursor' // Cursor + | 'droid' // Factory Droid + | 'kimi' // Kimi + | 'mistral-vibe' // Mistral Vibe + | 'qwen-code' // Qwen Code + | 'rovo' // Rovo Dev + | 'hermes' // Hermes Agent + | 'openclaw' // OpenClaw + | 'copilot' // GitHub Copilot CLI + | 'grok' // xAI Grok CLI + | 'devin' // Devin CLI + | 'ante' // Ante (Antigma Labs) + | 'trae' // Trae CLI + | 'prime-agent' // Prime Agent (Prime Intellect) + +/** Durable identity of a user-defined agent derived from a built-in base harness. + * Newly minted suffixes are canonical lowercase RFC 4122 UUIDs; ids are never reused. */ +export type CustomTuiAgentId = `custom-agent:${BuiltInTuiAgent}:${string}` + +/** Any agent identity a launch surface may request: a built-in or a custom derivative. + * Dynamic identity collections (defaults, disabled lists, quick commands, sessions) use + * this union; static behavior registries stay keyed by `BuiltInTuiAgent` and dynamic + * values must resolve through the catalog identity accessor first. */ +export type TuiAgent = BuiltInTuiAgent | CustomTuiAgentId + +export type CustomTuiAgent = { + id: CustomTuiAgentId + baseAgent: BuiltInTuiAgent + label: string + /** One executable argv element replacing the whole base command prefix; never reparsed. */ + commandOverride?: string + /** Freeform args template in the shell-independent v1 grammar (tokenized before interpolation). */ + args: string + env: Record + /** Whether launches started from paired devices may use this agent's env values on the host. */ + syncEnv: boolean +} + +/** Tombstone kept while any persisted owner still references the deleted id. It carries + * identity/label only — args, env, and executable override are never recoverable. */ +export type DeletedCustomTuiAgent = { + id: CustomTuiAgentId + baseAgent: BuiltInTuiAgent + label: string + deletedAt: number +} + +export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' + +/** Where the repo setup script runs when a worktree is created. + * - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab (default). + * - 'split-vertical': split the initial terminal pane with a vertical divider. + * - 'split-horizontal': split the initial terminal pane with a horizontal divider. */ +export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab' + +/** Direction used when the setup script launch mode is a split. */ +export type SetupSplitDirection = 'vertical' | 'horizontal' + +export type TerminalColorOverrides = { + foreground?: string + background?: string + cursor?: string + cursorAccent?: string + selectionBackground?: string + selectionForeground?: string + black?: string + red?: string + green?: string + yellow?: string + blue?: string + magenta?: string + cyan?: string + white?: string + brightBlack?: string + brightRed?: string + brightGreen?: string + brightYellow?: string + brightBlue?: string + brightMagenta?: string + brightCyan?: string + brightWhite?: string + // Why: xterm.js ITheme does not expose a `bold` key, but Ghostty users + // expect the setting to be preserved so a future renderer CSS override + // or xterm upgrade can honour it without a migration. + bold?: string +} + +export type TerminalQuickCommandScope = + | { + type: 'global' + } + | { + type: 'repo' + repoId: string + } + +export type TerminalQuickCommandAction = 'terminal-command' | 'agent-prompt' + +export type TerminalQuickCommandBase = { + id: string + label: string + scope?: TerminalQuickCommandScope +} + +export type TerminalCommandQuickCommand = TerminalQuickCommandBase & { + action?: 'terminal-command' + command: string + appendEnter: boolean +} + +export type TerminalAgentQuickCommand = TerminalQuickCommandBase & { + action: 'agent-prompt' + agent: TuiAgent + prompt: string +} + +export type TerminalQuickCommand = TerminalCommandQuickCommand | TerminalAgentQuickCommand + +export type OpenInApplication = { + id: string + label: string + command: string +} + +export type SourceControlViewMode = 'list' | 'tree' +export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first' + +export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' + +/** Strategy for the prefix prepended to worktree branch names. */ +export type BranchPrefixStrategy = 'git-username' | 'custom' | 'none' + +export type FloatingTerminalCwdRequest = { + path?: string + requireTrusted?: boolean +} + +/** Per-host overrides for client preferences that genuinely vary by execution + * host. NARROW by design: only settings whose value is meaningless to share + * across hosts belong here. + * - `displayLabel`: a client-side rename for the host shown in sidebar/pickers. + * - `defaultWorktreeLocation`: the host's root worktree directory; a remote + * SSH/runtime host has a different filesystem layout than the local Mac, so + * the client `workspaceDir` default cannot apply unchanged. */ +export type HostSettingOverrides = { + displayLabel?: string + defaultWorktreeLocation?: string +} + +/** Presentation mode for the experimental Agent Dashboard. */ +export type AgentDashboardMode = 'in-window' | 'popout' + +export type GlobalSettings = { + workspaceDir: string + /** Per-host overrides keyed by ExecutionHostId. Effective value for a + * host-varying setting is `host override ?? client default`. */ + hostSettingOverrides?: Partial> + nestWorkspaces: boolean + workspaceDirHistory?: OrcaWorkspaceLayout[] + refreshLocalBaseRefOnWorktreeCreate: boolean + /** Set once the user dismisses the "local main is behind" suggestion toast, so + * the nudge to enable refreshLocalBaseRefOnWorktreeCreate never shows again. */ + localBaseRefSuggestionDismissed: boolean + /** When enabled, Orca renames a workspace's auto-generated creature branch to + * a short name derived from the first prompt once work begins. Users can + * still turn this off from global Git settings. */ + autoRenameBranchFromWork: boolean + /** One-shot migration guard for the default-on rollout. Existing profiles + * without the guard are flipped on once; later explicit opt-outs stick. */ + autoRenameBranchFromWorkDefaultedOn?: boolean + branchPrefix: BranchPrefixStrategy + branchPrefixCustom: string + enableGitHubAttribution?: boolean + theme: 'system' | 'dark' | 'light' + /** Controls the left sidebar surface without changing terminal brightness. */ + leftSidebarAppearanceMode: LeftSidebarAppearanceMode + leftSidebarTintColor?: string + leftSidebarTintOpacity?: number + uiLanguage: UiLanguage + appIcon: AppIconId + appFontFamily: string + editorAutoSave: boolean + editorAutoSaveDelayMs: number + editorMinimapEnabled: boolean + /** Opt-in code-editor font; empty (the default) keeps following `terminalFontFamily`. */ + editorFontFamily?: string + /** Defaults on for profiles saved before file-editor wrapping became configurable. */ + editorWordWrap?: boolean + /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ + richMarkdownSpellcheckEnabled?: boolean + /** Whether local markdown review note controls and the review panel are shown. */ + markdownReviewToolsEnabled: boolean + /** Why: mirrors terminal selection-paste muscle memory without mutating the + * normal system clipboard; Linux and macOS enable it by default, Windows + * leaves middle-click semantics unchanged unless the user opts in. */ + primarySelectionMiddleClickPaste?: boolean + /** One-shot migration guard for turning the Linux default on for profiles + * that persisted the earlier off-by-default value. */ + primarySelectionMiddleClickPasteDefaultedForLinux?: boolean + /** One-shot migration guard for widening the terminal-style default to + * Linux/macOS while preserving later explicit opt-outs. */ + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults?: boolean + terminalFontSize: number + terminalFontFamily: string + terminalFontWeight: number + terminalLineHeight: number + terminalScrollSensitivity: number + terminalFastScrollSensitivity: number + terminalTuiScrollSensitivity: number + /** One-shot migration guard for moving inherited TUI wheel reports from 3 to 1. */ + terminalTuiScrollSensitivityDefaultedToOne?: boolean + /** Terminal renderer policy. + * - 'auto': try xterm WebGL and fall back to DOM when unsupported or risky. + * - 'on': always try xterm WebGL. + * - 'off': keep terminal rendering on xterm's DOM renderer. */ + terminalGpuAcceleration: 'auto' | 'on' | 'off' + /** Whether to enable programming-ligatures rendering via + * `@xterm/addon-ligatures`. + * - `'auto'` (default): enabled only when the configured font is known to + * ship ligatures (Fira Code, JetBrains Mono, Cascadia Code, etc.). This + * keeps the out-of-the-box experience right for users who install a + * ligature font without touching settings. + * - `'on'` / `'off'`: explicit override. Never changes when the user + * switches fonts, so "off" always stays off. */ + terminalLigatures: 'auto' | 'on' | 'off' + terminalCursorStyle: 'bar' | 'block' | 'underline' + /** One-shot migration guard for moving inherited cursor defaults to block. */ + terminalCursorStyleDefaultedToBlock?: boolean + terminalCursorBlink: boolean + terminalThemeDark: string + terminalCustomThemes?: TerminalCustomTheme[] + terminalDividerColorDark: string + terminalUseSeparateLightTheme: boolean + terminalThemeLight: string + terminalDividerColorLight: string + terminalInactivePaneOpacity: number + terminalActivePaneOpacity: number + terminalPaneOpacityTransitionMs: number + terminalDividerThicknessPx: number + terminalBackgroundOpacity?: number + terminalColorOverrides?: TerminalColorOverrides + terminalPaddingX?: number + terminalPaddingY?: number + terminalMouseHideWhileTyping?: boolean + terminalWordSeparator?: string + terminalCursorOpacity?: number + terminalQuickCommands?: TerminalQuickCommand[] + windowBackgroundBlur?: boolean + /** Windows-only: close (X) hides to tray instead of quitting; the tray icon is always present regardless. */ + minimizeToTrayOnClose?: boolean + /** macOS: toggles the additive menu-bar entry (Orca survives last-window close); doesn't change Dock behavior. */ + showMenuBarIcon?: boolean + /** Windows convention: right-click pastes; macOS/Linux keep the context menu. */ + terminalRightClickToPaste: boolean + /** One-shot guard distinguishing the old global true default from a per-platform choice. */ + terminalRightClickToPasteDefaultedForPlatform?: boolean + /** Windows-only: COMSPEC always points to cmd.exe, so this explicit shell (default 'powershell.exe') overrides it. */ + terminalWindowsShell: string + /** Pins the WSL distro for terminals/agent scans instead of WSL's current global default. */ + terminalWindowsWslDistro?: string | null + /** Account/auth location; auto follows the global Windows runtime while host/wsl pin it. */ + localAccountRuntime: 'auto' | 'host' | 'wsl' + localAccountWslDistro?: string | null + /** One-shot guard for migrating the legacy host default to auto. */ + localAccountRuntimeDefaultedToAutoForAllUsers?: boolean + /** Independent from the terminal shell so users can inspect Windows vs WSL agent PATH state without changing it. */ + localAgentRuntime?: 'host' | 'wsl' + localAgentWslDistro?: string | null + /** Why: global is only the default policy; project-level runtime preference wins. */ + localWindowsRuntimeDefault: GlobalWindowsRuntimeDefault + /** 'auto' resolves to PowerShell 7+ when present, else falls back to inbox Windows PowerShell. */ + terminalWindowsPowerShellImplementation: 'auto' | 'powershell.exe' | 'pwsh.exe' + terminalFocusFollowsMouse: boolean + /** X11/gnome-terminal "copy on select": selecting text auto-copies to the clipboard; default off. */ + terminalClipboardOnSelect: boolean + /** Enables OSC 52 clipboard writes for TUIs (tmux/Zellij/nvim, incl. over SSH); default on. Clipboard *queries* stay blocked and payload size is capped, so this is write-only exposure. */ + terminalAllowOsc52Clipboard: boolean + /** One-shot stamp: profiles saved under the old off default get flipped on once, after which an explicit opt-out sticks. */ + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean + /** Experimental Claude Agent Teams; native panes use a tmux-compatible shim so teammate output stays on the normal PTY path. */ + claudeAgentTeamsMode?: ClaudeAgentTeamsMode + /** Where the repo setup script runs on workspace create; defaults to a background "Setup" tab to keep the main terminal usable. */ + setupScriptLaunchMode: SetupScriptLaunchMode + terminalScrollbackRows: number + /** Optional app-level proxy for Electron networking and local PTYs; empty preserves system/inherited proxy env. */ + httpProxyUrl?: string + /** Optional semicolon/comma/newline-separated bypass rules for httpProxyUrl. */ + httpProxyBypassRules?: string + /** Why: corporate TLS-intercepting proxies can break HTTP/2 downloads; opt-in Chromium process-wide HTTP/1.1 switch. */ + electronHttp1CompatibilityMode?: boolean + /** Opt-in in-app browsing (isolated guest surface); default keeps links opening in the system browser. */ + openLinksInApp: boolean + /** Worktree-scoped localhost hostnames to distinguish tabs; opt-in since a non-localhost host can break apps binding cookies/sessions to localhost. */ + localhostWorktreeLabelsEnabled?: boolean + /** Tracks the one-time first-use prompt for terminal link routing (avoid silently changing where links open). */ + openLinksInAppPreferencePrompted: boolean + /** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */ + openLinksInAppModifierInverts?: boolean + /** Show terminal link actions on plain click; off restores modifier-click-only terminal links. */ + terminalLinkActionPopoverEnabled?: boolean + /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ + openAgentTabsInChatByDefault?: boolean + /** Experimental native chat surface for Claude/Codex sessions; off by default. */ + experimentalNativeChat?: boolean + /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ + nativeChatSessionOptions?: PersistedNativeChatSessionOptions + /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ + openInApplications?: OpenInApplication[] + /** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */ + rightSidebarOpenByDefault: boolean + showGitIgnoredFiles?: boolean + /** Preferred Source Control changes layout. Per-user, not per-workspace. */ + sourceControlViewMode: SourceControlViewMode + /** Preferred Source Control group order. Per-user, not per-workspace. */ + sourceControlGroupOrder: SourceControlGroupOrder + /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ + sourceControlCompareAgainstUpstream: boolean + /** Whether to show the Orca app name in the titlebar. */ + showTitlebarAppName: boolean + /** Hides the Tasks sidebar button (also removes it from keyboard navigation). */ + showTasksButton: boolean + /** Only toggles the sidebar shortcut; Automations stay reachable from Settings/View menu. */ + showAutomationsButton?: boolean + /** Deprecated: Artifacts are always available. Use showArtifactsButton for sidebar visibility. */ + artifactsEnabled?: boolean + /** Capability gate for agent-driven publishing; off until granted, enforced in main, not just the UI. */ + artifactSharingEnabled?: boolean + /** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */ + showArtifactsButton?: boolean + /** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */ + showMobileButton?: boolean + /** Pinned workspaces show in one sidebar location by default; opt in to also show them in their natural groups. */ + showPinnedWorktreesInGroups?: boolean + /** How Ctrl+Tab picks the next visible tab; optional (older profiles), readers default to MRU. */ + ctrlTabOrderMode?: CtrlTabOrderMode + /** Orca-first keeps app shortcuts from TUIs; terminal-first is opt-in to let shell/TUI bindings win. */ + terminalShortcutPolicy?: TerminalShortcutPolicy + /** Floating Workspace: global surface for terminal/browser/markdown tabs outside repo/worktree context. */ + floatingTerminalEnabled: boolean + /** One-shot migration flag for the floating-workspace default-on rollout; after migration an explicit off sticks. */ + floatingTerminalDefaultedForAllUsers?: boolean + /** Start dir for new floating-workspace terminal tabs; empty or '~' = home dir. */ + floatingTerminalCwd: string + /** Picker-approved floating-workspace dirs reauthorized across restarts; renderer text alone must not populate this. */ + floatingTerminalTrustedCwds?: string[] + /** One-shot migration marker for legacy floating workspace cwd trust grants. */ + floatingTerminalCwdMigratedToAppWorkspace?: boolean + /** Where the Floating Workspace toggle is shown; defaults to the floating button for discoverability. */ + floatingTerminalTriggerLocation: FloatingTerminalTriggerLocation + /** Legacy keyboard-shortcut overrides; new writes go to ~/.orca/keybindings.json, migrated once when present. */ + keybindings?: KeybindingOverrides + diffDefaultView: 'inline' | 'side-by-side' + diffWordWrap: boolean + combinedDiffFileTreeVisibleByDefault: boolean + /** Bot-marked comment-author logins (stored lowercased); escape hatch for review bots on regular accounts that defeat provider metadata/heuristics. */ + prBotAuthorOverrides: string[] + notifications: NotificationSettings + /** Countdown after a Claude agent goes idle showing time left before the prompt cache expires. */ + promptCacheTimerEnabled: boolean + /** Prompt-cache TTL (ms); only 300000 (5 min standard) or 3600000 (1 hr, extended-TTL plans). */ + promptCacheTtlMs: number + /** Why: durable main-owned pref so Orca can prepare shared ~/.codex before the renderer hydrates. */ + codexManagedAccounts: CodexManagedAccount[] + activeCodexManagedAccountId: string | null + activeCodexManagedAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection + /** Why: persist only per-account auth (not a CLAUDE_CONFIG_DIR swap) so switching accounts doesn't fork Claude's shared chat/session context. */ + claudeManagedAccounts: ClaudeManagedAccount[] + activeClaudeManagedAccountId: string | null + activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection + /** Per-worktree shell history file so ArrowUp doesn't surface other worktrees' commands. Defaults to true. */ + terminalScopeHistoryByWorktree: boolean + /** Kill switch for hidden terminal view parking: unmount long-hidden panes while a pane-less watcher keeps PTY side effects alive. */ + terminalHiddenViewParking?: boolean + /** Kill switch for SSH terminal parking (C1): SSH panes park like local ones; reveal restores from main's headless model, falling back to relay replay. */ + terminalSshViewParking?: boolean + /** Kill switch for the hidden-worktree retention budget (C1): force-parks the least-recently-hidden un-parkable worktrees beyond a count budget or TTL. */ + terminalHiddenWorktreeRetentionBudget?: boolean + /** Kill switch for the browser-guest worktree retention budget: destroys the least-recently-activated hidden worktrees' webview guests beyond an LRU count budget. */ + browserGuestWorktreeRetentionBudget?: boolean + /** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */ + terminalMainSideEffectAuthority?: boolean + /** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */ + terminalHiddenDeliveryGate?: boolean + /** Kill switch for main's model query responder (Phase 5); active only when both Phase-4 gates are also on. */ + terminalModelQueryAuthority?: boolean + /** Which agent to pre-select in the new-workspace composer. + * - 'auto': first detected enabled built-in in canonical order (host-resolved) + * - 'blank': blank terminal (no agent launched) + * - TuiAgent: a specific agent id + * - null: invalid/repaired default needing user attention (never auto-selects). + * Legacy pre-v1 `null` meant Auto; the one-time schema migration maps it to 'auto'. */ + defaultTuiAgent: TuiAgent | 'auto' | 'blank' | null + /** Agents hidden from future picker and automatic launch choices. Authoritative + * enabled-state for built-ins and custom agents alike. Detection remains a raw + * PATH capability snapshot. */ + disabledTuiAgents: TuiAgent[] + /** Master switch for the experimental plugin system. Off by default: no + * discovery, no panels, no plugin code paths run at all. */ + pluginSystemEnabled: boolean + /** Qualified plugin keys (`publisher.id`) the user disabled. Discovered + * plugins stay listed but are not activated. */ + disabledPlugins: string[] + /** Consent records: qualified plugin key → capability/worker-trust fingerprint. + * A plugin whose current fingerprint differs is pending again, so an update + * crossing either trust boundary re-prompts before code runs. Absent key = + * never consented. */ + pluginConsents: Record + /** Local directories loaded as dev-mode plugins (manifest hot-reload). */ + devPluginPaths: string[] + /** User-defined custom agents in creation order (the persisted array is the + * creation-order authority; normalization never reorders surviving rows). */ + customTuiAgents?: CustomTuiAgent[] + /** Reference-counted tombstones for deleted custom agents; pruned only after an + * authoritative recheck proves zero references across every owner store. */ + deletedCustomTuiAgents?: DeletedCustomTuiAgent[] + /** Agent catalog persistence schema version; 1 = custom-agent catalog with 'auto' default. */ + agentCatalogSchemaVersion?: number + /** Monotonic revision incremented once per atomic agent-catalog mutation. */ + agentCatalogRevision?: number + /** Monotonic revision incremented once per atomic agent-reference mutation. */ + agentReferenceRevision?: number + /** One-shot guard so the experimental Claude Agent Teams launch mode starts + * hidden for existing profiles without overriding later user opt-ins. */ + claudeAgentTeamsDefaultDisabledMigrated?: boolean + /** Why: worktree deletion is destructive (rm -rf of the working dir), so confirm by default. */ + skipDeleteWorktreeConfirm: boolean + /** Why: closing a terminal with child processes kills foreground work; keep this skip separate from other confirmations. */ + skipCloseTerminalWithRunningProcessConfirm: boolean + /** Why: deleting an automation also deletes its run history; keep this skip separate from worktree deletion. */ + skipDeleteAutomationConfirm: boolean + /** Why: deleting an artifact breaks a public link others may already hold; keep this skip separate from local deletions. */ + skipDeleteArtifactConfirm: boolean + /** Why: a Codex rate-limit reset spends a scarce credit on the live account; keep this skip separate from local confirmations. */ + skipCodexRateLimitResetConfirm: boolean + /** Default preset in the new-workspace GitHub task view. */ + defaultTaskViewPreset: TaskViewPresetId + /** Persisted last-used task source so Tasks reopens to the same provider instead of defaulting to GitHub. */ + defaultTaskSource: TaskProvider + /** Persisted visible task providers; hides unused providers from Tasks chrome and sidebar shortcuts. */ + visibleTaskProviders: TaskProvider[] + /** Why: one-shot guard to make Jira visible for existing profiles once, without re-adding after a later opt-out. */ + visibleTaskProvidersDefaultedForJira: boolean + /** Persisted repo selection (cross-repo tasks view). null = sticky-all (includes future-added repos); + * string[] = frozen curated subset (ineligible ids dropped on load; empty after drop is treated as null). */ + defaultRepoSelection: string[] | null + /** Persisted Linear team selection (tasks view). Same nullable-array pattern as + * defaultRepoSelection: null = sticky-all, string[] = frozen subset of team IDs. */ + defaultLinearTeamSelection: string[] | null + /** Session cookie for OpenCode Go rate-limit fetching. Stored encrypted. */ + opencodeSessionCookie: string + /** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */ + opencodeWorkspaceId: string + /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ + minimaxGroupId: string + /** Comma-separated MiniMax model names to show in the status bar usage window. */ + minimaxUsageModels: string + /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ + geminiCliOAuthEnabled: boolean + /** Per-built-in CLI command overrides. A missing key means use the catalog default binary + * name. Custom-agent configuration lives only on `CustomTuiAgent`. */ + agentCmdOverrides: Partial> + /** Why: Orca bridges Codex session history from the user's real Codex home into + * its managed home so /resume finds it, but defaults to ~/.codex. Users who run + * Codex with a custom CODEX_HOME can point history discovery at that folder here. + * History-only: this does not change which account/config/hooks Orca uses. */ + codexSessionSourceHome?: { + /** Absolute host path; empty/undefined falls back to ~/.codex. */ + host?: string + /** Per-WSL-distro absolute Linux path; missing distro falls back to /.codex. */ + wsl?: Record + } + /** Per-built-in default CLI arguments appended after the binary/path and before prompts. */ + agentDefaultArgs?: Partial> + /** Per-built-in launch environment defaults used when yolo mode is exposed as env. */ + agentDefaultEnv?: Partial>> + /** One-shot guard for adding yolo-mode default args to untouched agent launch profiles. */ + agentYoloDefaultsMigrated?: boolean + /** Why: disabling must persist so startup doesn't reinstall global agent hook entries the user just removed. */ + agentStatusHooksEnabled: boolean + /** Dismissed freshness tuples: no write authority, just suppress re-nudging the same official placement/revision. */ + dismissedSkillFreshnessNudges?: string[] + /** Why: generated tab titles are subjective, so they stay opt-in and manual renames win. */ + tabAutoGenerateTitle: boolean + /** Why: pinned tabs can still be closed via keyboard/native-menu; this gates that behind a confirmation. Defaults on. */ + confirmClosePinnedTab: boolean + /** When true, Orca requests local awake assertions while hook-reported agents are working. */ + keepComputerAwakeWhileAgentsRun: boolean + /** Optional for mixed-version compatibility; the legacy boolean maps true to Auto. */ + computerAwakeMode?: ComputerAwakeMode + /** macOS Option key: compose layout chars (@ German, € French) vs act as Meta/Esc for readline. + * 'auto' (default) = layout-aware via navigator.keyboard.getLayoutMap() (US → Meta, else compose); + * 'false' = compose; 'true' = Meta on both Option keys; 'left'/'right' = only that key is Meta. + * See docs/terminal-option-key-layout-aware-default.md. */ + terminalMacOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' + /** One-shot migration guard for the 'auto' rollout. Old default 'true' was ambiguous (explicit vs default); + * on first upgrade launch, reset a persisted 'true' to 'auto' so non-US keyboards aren't broken by the stale default. */ + terminalMacOptionAsAltMigrated: boolean + /** Whether macOS terminal input maps the physical JIS Yen (¥) key to backslash, per common terminal expectation. */ + terminalJISYenToBackslash: boolean + experimentalMobile: boolean + /** Why: iOS Simulator is default-on for capable macOS hosts; this is the durable off switch (hides UI, blocks CLI attach). */ + mobileEmulatorEnabled?: boolean + /** Preferred iOS Simulator UDID for UI auto-attach and agent CLI attach. */ + mobileEmulatorDefaultDeviceUdid?: string | null + /** Explicit Android SDK root for when auto-discovery (ANDROID_HOME / default path) fails; null (default) auto-discovers. */ + androidSdkPath?: string | null + /** Auto-restore window (ms) for a phone-fit PTY after the last mobile subscriber leaves. + * `null` (default) holds phone size indefinitely; a finite value schedules restore. + * Clamped on read to [5_000ms, 60min]. See docs/mobile-fit-hold.md. */ + mobileAutoRestoreFitMs: number | null + /** Preferred mobile pairing path for new QR codes. Missing/'automatic' = Anywhere (Relay + local); + * explicit 'local-only' = same-network only. */ + mobilePairingConnectionMode?: 'automatic' | 'local-only' + /** Explicit custom address restored when generating future mobile pairing codes. */ + mobilePairingCustomAddress?: string | null + /** Saved custom addresses available in both mobile pairing pickers. */ + mobilePairingCustomAddresses?: string[] + /** Experimental: floating animated pet in the bottom-right corner. Opt-in cosmetic; + * off never mounts the overlay, and toggling takes effect instantly (renderer-side). */ + experimentalPet: boolean + /** Legacy persisted key from before the sidekick -> pet rename; read only during migration, new writes use experimentalPet. */ + experimentalSidekick?: boolean + /** Experimental: left-sidebar Agents view — threaded feed of agent completions, blocking/unread state, worktree creation. */ + experimentalActivity: boolean + /** Experimental: pop-out Kanban dashboard for monitoring and opening agent terminals across worktrees. */ + experimentalAgentDashboardPopout?: boolean + /** How the Agent Dashboard opens: an in-window companion board or a separate pop-out window. Defaults to in-window. */ + experimentalAgentDashboardMode?: AgentDashboardMode + /** Includes stale quiet agents as a fourth Agent Dashboard column. */ + experimentalAgentDashboardShowIdle?: boolean + /** One-shot migration guard for defaulting the Agents view off; later explicit opt-ins persist normally. */ + experimentalActivityDefaultedOffForAllUsers?: boolean + /** Experimental: persistent terminal-pane attention ring for bell + agent-completion events. Opt-in while tuning signal/noise. */ + experimentalTerminalAttention: boolean + /** Experimental: automatically sleep completed, resumable background agent terminals. */ + experimentalAgentHibernation?: boolean + /** Milliseconds a completed agent must stay idle before hibernation can be considered. */ + agentHibernationIdleMs?: number + /** Experimental: opt-in preview of the updated worktree-card layout and metadata behavior. */ + experimentalNewWorktreeCardStyle?: boolean + /** Experimental: per-workspace on-demand environment recipes and setup surface. */ + experimentalEphemeralVms?: boolean + /** Compact worktree cards: hide the metadata row when title and branch say the same thing. */ + compactWorktreeCards: boolean + /** Legacy persisted key from the Experimental rollout; new writes use compactWorktreeCards. */ + experimentalCompactWorktreeCards?: boolean + /** Active non-local runtime environment for client-routed RPC; null keeps local desktop behavior. */ + activeRuntimeEnvironmentId?: string | null + /** GitHub Project mode state (pinned/recent/active project, last view per project). + * Optional for pre-feature profiles; the persistence merge hydrates the default. */ + githubProjects?: GitHubProjectSettings + /** AI commit-message config (agent, model, per-model thinking, prompt suffix). Optional to avoid migrating existing profiles. */ + commitMessageAi?: CommitMessageAiSettings + /** Source-control AI generation settings for commit messages and hosted-review drafts. */ + sourceControlAi?: SourceControlAiSettings + /** GitLab project preferences (pinned + recent paths). Optional for pre-GitLab profiles; persistence merge fills the default. */ + gitlabProjects?: GitLabProjectSettings + /** Anonymous product-telemetry state; optional until the one-shot Store.load() migration populates it. + * Holds only consent + identity, not volatile counters — those would amplify the debounced settings write. */ + telemetry?: { + /** New users: true at install. Existing users: null until they resolve the first-launch banner. */ + optedIn: boolean | null + /** Anonymous UUID v4. Generated on first run. Stable across launches; not surfaced in the UI. */ + installId: string + /** Cohort marker: true for pre-existing profiles (gates the opt-in banner), false for fresh installs. */ + existedBeforeTelemetryRelease: boolean + } + /** One-shot cohort marker for the tab-switch keybinding swap. 'pending' = + * pre-existing install (seed pins old chords, then flips to 'done'); 'done' = fresh install. */ + tabSwitchKeybindingSeed?: 'pending' | 'done' + /** Local voice/dictation config. Optional for pre-voice profiles; getDefaultSettings() hydrates defaults via the persistence merge. */ + voice?: VoiceSettings +} + +export type OrcaWorkspaceLayout = { + path: string + nestWorkspaces: boolean +} + +export type CommitMessageAiModelCapability = { + id: string + label: string + thinkingLevels?: { id: string; label: string }[] + defaultThinkingLevel?: string +} + +export type CommitMessageAiSettings = { + enabled: boolean + /** A TuiAgent id, the literal `'custom'` for a user-supplied command, or null. */ + agentId: TuiAgent | 'custom' | null + /** Per-agent: switching agents preserves the previously-picked model. */ + selectedModelByAgent: Partial> + /** Host-scoped model selections; dynamic agents can expose different models per SSH target. */ + selectedModelByAgentByHost?: Partial>>> + /** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */ + discoveredModelsByAgent?: Partial> + /** Host-scoped dynamic model discovery cache. */ + discoveredModelsByAgentByHost?: Partial< + Record>> + > + /** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */ + selectedThinkingByModel: Record + /** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */ + customPrompt: string + /** Command template for agentId === 'custom'; {prompt} substitutes the diff prompt via argv, else the prompt is piped via stdin. */ + customAgentCommand: string +} + +export type GhosttyImportPreview = { + found: boolean + configPath?: string + configPaths?: string[] + diff: Partial + unsupportedKeys: string[] + error?: string +} + +// Subset of onboarding Ghostty DiscoveryState statuses that emit telemetry; UI-only 'idle'/'detecting' don't. +export type DiscoveryStatusEmitted = 'found' | 'absent' | 'imported' + +export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'test' + +export type NotificationDispatchRequest = { + source: NotificationEventSource + notificationId?: string + /** Why: useful for fast native failures, but macOS can still drop notifications after 'show'. */ + requireDisplayConfirmation?: boolean + worktreeId?: string + /** Stable `${tabId}:${leafId}` terminal pane key for click-to-focus routing. */ + paneKey?: string + repoLabel?: string + worktreeLabel?: string + hasMultipleActiveRepos?: boolean + terminalTitle?: string + isActiveWorktree?: boolean + agentType?: AgentType + agentState?: AgentStatusState + agentPrompt?: string + agentToolName?: string + agentToolInput?: string + agentLastAssistantMessage?: string + agentInterrupted?: boolean +} + +export type NotificationDispatchResult = { + delivered: boolean + /** Why delivery was skipped (set when delivered is false); 'blocked-by-system' = macOS would silently swallow it. */ + reason?: + | 'disabled' + | 'source-disabled' + | 'suppressed-focus' + | 'cooldown' + | 'not-supported' + | 'not-displayed' + | 'blocked-by-system' + | 'invalid-request' +} + +export type NotificationDismissResult = { + dismissed: number +} + +export type NotificationSoundResult = { + played: boolean + reason?: + | 'missing-path' + | 'invalid-path' + | 'unsupported-type' + | 'too-large' + | 'read-failed' + | 'playback-failed' + | 'deduped' +} + +export type NotificationSoundDataResult = + | { + ok: true + data: Uint8Array + mimeType: string + path: string + } + | { + ok: false + reason: Exclude + } + +export type NotificationSoundPathResult = + | { ok: true; path: string } + | { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } + +export type OnboardingOutcome = 'completed' | 'dismissed' + +export type OnboardingChecklistState = { + addedRepo: boolean + choseAgent: boolean + ranFirstAgent: boolean + ranSecondAgentOnSameTask: boolean + triedCmdJ: boolean + shapedSidebar: boolean + reviewedDiff: boolean + openedPr: boolean + addedFolder: boolean + openedFile: boolean + ranAgentOnFile: boolean + // Why: UI state flag (panel visibility), not an activation event; telemetry checklist enum omits it. + dismissed: boolean +} + +export type OnboardingState = { + // Why: step meanings change when pages are removed; version marker prevents migration re-running on new progress. + flowVersion: number + closedAt: number | null + outcome: OnboardingOutcome | null + // Sentinel -1 = not started; 1..5 = highest finished wizard step. number (not union) because callers clamp via Math.max/min. + lastCompletedStep: number + checklist: OnboardingChecklistState +} + +export type NotificationPermissionStatusResult = { + supported: boolean + platform: NodeJS.Platform + requested: boolean +} + +/** macOS notification permission outcome: authoritative native UNUserNotificationCenter readout, else a weaker + * delivery-probe fallback; 'awaiting-decision' = permission dialog unanswered. */ +export type NotificationDeliveryProbeResult = { + state: 'delivered' | 'blocked' | 'awaiting-decision' | 'unsupported' + /** True when the state comes from the native authorization readout (vs. the delivery-probe fallback). */ + authoritative: boolean +} + +export type WorktreeCardProperty = + | 'status' + | 'unread' + // Legacy persisted preference. CI status is now represented by linked PR metadata. + | 'ci' + // Migration-only: legacy detailed cards showed branch identity as a visible row. + | 'branch' + // Task metadata on workspace cards; provider-specific persisted values kept for older profiles. + | 'issue' + | 'linear-issue' + | 'jira-issue' + | 'pr' + | 'automation' + // Badge marking workspaces created through `orca worktree create`. + | 'cli' + | 'comment' + | 'ports' + // Inline agent-activity list rendered in each workspace card; on by default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts). + | 'inline-agents' + +export type WorktreeCardMode = 'Default' | 'Compact' + +export type AgentActivityDisplayMode = 'compact' | 'full' + +export type StatusBarItem = + | 'claude' + | 'codex' + | 'gemini' + | 'antigravity' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'ssh' + | 'resource-usage' + | 'ports' +export type FloatingTerminalTriggerLocation = 'floating-button' | 'status-bar' + +export type TaskResumeState = { + githubMode?: 'items' | 'project' + githubItemsPreset?: TaskViewPresetId | null + githubItemsQuery?: string + githubProjectHiddenFieldIdsByView?: Record + linearMode?: 'issues' | 'projects' | 'views' | 'in-orca' + linearPreset?: 'assigned' | 'created' | 'all' | 'completed' + linearQuery?: string + linearContext?: { + kind: 'project' | 'view' + id: string + workspaceId: LinearConcreteWorkspaceId + model?: LinearCustomViewModel + } + jiraPreset?: 'assigned' | 'reported' | 'all' | 'done' + jiraQuery?: string +} + +export type RightSidebarTab = + | 'explorer' + | 'search' + | 'vault' + | 'workspaces' + | 'pr-checks' + | 'source-control' + | 'checks' + | 'ports' + // Plugin-contributed panels are keyed `plugin:/` so the + // static union stays closed while plugin tabs remain type-representable. + | `plugin:${string}` +export type ActiveRightSidebarTab = Exclude +export type RightSidebarExplorerView = 'files' | 'search' + +export type ProjectOrderBy = 'manual' | 'recent' +export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}` +export type VisibleWorkspaceHostIds = Exclude[] | null +export type WorkspaceHostOrder = Exclude[] +export type ManualRepoOrderEntry = { + hostId: WorkspaceHostOrder[number] + repoId: string +} + +/** The active top-level section shown in the main content area. */ +export type TopLevelView = + | 'terminal' + | 'settings' + | 'tasks' + | 'activity' + | 'automations' + | 'space' + | 'skills' + | 'artifacts' + | 'mobile' + +export type PersistedUIState = { + lastActiveRepoId: string | null + lastActiveWorktreeId: string | null + /** Active top-level view at save time, restored on relaunch; sanitized to 'terminal' if unknown or now-gated. */ + activeView: TopLevelView + sidebarWidth: number + rightSidebarOpen: boolean + rightSidebarTab: RightSidebarTab + rightSidebarExplorerView: RightSidebarExplorerView + rightSidebarWidth: number + markdownTocPanelWidth?: number + combinedDiffFileTreeWidth?: number + groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' + sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' + /** Project header ordering in `groupBy: 'repo'`, independent of `sortBy`: 'manual' uses persisted order + header drag, 'recent' by latest visible activity. */ + projectOrderBy: ProjectOrderBy + /** Deprecated; the Active only filter is retired and ignored on hydration. */ + showActiveOnly: boolean + /** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */ + hideSleepingWorkspaces?: boolean + /** Which execution hosts the sidebar shows; `all` = mixed view, specific IDs focus without tearing down other hosts' sessions. */ + workspaceHostScope?: WorkspaceHostScope + /** Which execution hosts the sidebar shows; `null` = sticky all-hosts so new hosts appear automatically. */ + visibleWorkspaceHostIds?: VisibleWorkspaceHostIds + /** User-defined sidebar order for host sections; missing/new hosts append in discovered order. */ + workspaceHostOrder?: WorkspaceHostOrder + /** Desktop-owned all-host repo order; host-qualified identities keep a manual cross-host interleaving while each host owns its local permutation. */ + manualRepoOrder?: ManualRepoOrderEntry[] + /** Deprecated legacy positive-form setting. Ignored on hydration. */ + showSleepingWorkspaces?: boolean + /** Deprecated legacy name used by a short-lived build. Ignored on hydration. */ + showInactiveWorkspaces?: boolean + /** Hide the repo's checked-out branch from workspace nav (sidebar, Cmd+J); folder-mode repos are unaffected (empty-branch worktrees excluded). */ + hideDefaultBranchWorkspace: boolean + /** Hide workspaces created by automation new-per-run dispatches. */ + hideAutomationGeneratedWorkspaces?: boolean + /** Hide workspaces created through `orca worktree create`. */ + hideCliCreatedWorkspaces?: boolean + /** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */ + hideDetachedHeadWorkspaces?: boolean + /** Hide workspaces with known provenance from another paired device or the host UI. */ + hideWorkspacesFromOtherDevices?: boolean + /** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */ + alwaysShowDefaultBranchWorkspace?: boolean + /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ + showDotfilesByWorktree?: Record + filterRepoIds: string[] + collapsedGroups: string[] + uiZoomLevel: number + editorFontZoomLevel: number + worktreeCardProperties: WorktreeCardProperty[] + /** One-shot migration flag for deriving card properties from the two worktree card modes. */ + _worktreeCardModeDefaulted?: boolean + agentActivityDisplayMode?: AgentActivityDisplayMode + workspaceStatuses?: WorkspaceStatusDefinition[] + workspaceBoardOpacity?: number + workspaceBoardColumnWidth?: number + syncTaskStatusFromWorkspaceBoard?: boolean + /** One-shot migration flag for a short-lived build that persisted default statuses in reverse order; once stamped, ordering is never re-inferred from IDs/labels. */ + _workspaceStatusesDefaultOrderMigrated?: boolean + /** One-shot repair flag for the exact default payload a short-lived build persisted in reverse workflow order. */ + _workspaceStatusesReorderedDefaultRepaired?: boolean + /** One-shot migration flag for default status workflow labels/visuals; only exact legacy defaults migrate, customized statuses preserved. */ + _workspaceStatusesDefaultWorkflowMigrated?: boolean + /** One-shot migration flag for the old default status visuals; once stamped, user-authored colors/icons are preserved. */ + _workspaceStatusesDefaultVisualsMigrated?: boolean + /** One-shot migration flag for adding the default-on Ports status item. */ + _portsStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Kimi status item. */ + _kimiStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on MiniMax status item. */ + _minimaxStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Antigravity status item. */ + _antigravityStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Grok status item. */ + _grokStatusBarDefaultAdded?: boolean + statusBarItems: StatusBarItem[] + statusBarVisible: boolean + /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ + usagePercentageDisplay?: UsagePercentageDisplay + /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ + statusBarUsageMode?: StatusBarUsageMode + dismissedUpdateVersion: string | null + lastUpdateCheckAt: number | null + /** Dev-only update channel override; absent means the build's own channel. */ + releaseChannelOverride?: ReleaseChannel | null + pendingUpdateNudgeId?: string | null + dismissedUpdateNudgeId?: string | null + /** Whether Orca already tried triggering the macOS notification permission dialog; prevents re-firing every launch. */ + notificationPermissionRequested?: boolean + /** Once the "your sessions won't be interrupted" reassurance card is seen, never show it again. */ + updateReassuranceSeen?: boolean + /** Per-paneKey "row visited" timestamps that mute seen inline-agent rows; persisted because rows survive restart, else acked rows return bold. Renderer-owned via ui:set. */ + acknowledgedAgentsByPaneKey?: Record + /** User-hidden setup-guide sidebar entry; a reversible declutter pref (Help menu stays available), not completion. */ + setupGuideSidebarDismissed?: boolean + /** One-shot marker for the browser setup-guide milestone; profiles missing it are evaluated once in the renderer (completion needs runtime probes). */ + setupGuideBrowserMilestoneMigrated?: boolean + /** Existing users who completed/dismissed the pre-browser checklist stay complete after the browser milestone is added. */ + setupGuideBrowserMilestoneLegacyComplete?: boolean + /** User-dismissed browser import toolbar hint; import stays available from Settings > Browser and the overflow menu. */ + browserImportHintHidden?: boolean + /** Why: Windows-only. Set once on first hide to tray so the "Orca is still running" notice shows only once. */ + trayMinimizeNoticeShown?: boolean + /** Set by the OSC 52 default-on migration when it overrode a persisted `false`; the renderer shows one notice and clears it. */ + osc52ClipboardDefaultOnNoticePending?: boolean + /** User dismissed the first-run Mobile Emulator intro; reversible only by re-enabling the feature in Settings. */ + mobileEmulatorTabIntroDismissed?: boolean + /** User deferred the in-pane Mobile Emulator CLI + skill setup guide. */ + mobileEmulatorAgentSetupDismissed?: boolean + /** One-shot rollout notice for manual project ordering default; absent or true keeps the sidebar callout hidden. */ + projectOrderManualDefaultNoticeDismissed?: boolean + /** One-shot notice that usage meters show percent used, not remaining; absent resolves on load (new profiles dismissed, upgraded see it once). */ + usagePercentageDisplayChangeNoticeDismissed?: boolean + /** User-hidden empty-state usage CTA; permanently hides the "Connect AI accounts" prompt even if providers are later disconnected. */ + usageEmptyStateDismissed?: boolean + /** URL for new browser tabs; null = blank tab. */ + browserDefaultUrl?: string | null + browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null + /** Electron browser zoom level applied when a new local browser tab is created. */ + browserDefaultZoomLevel?: number + /** Optional Kagi private-session link used only when Kagi is the search engine. */ + browserKagiSessionLink?: string | null + /** Saved window bounds so the app restores last position/size instead of maximizing each launch. */ + windowBounds?: { x: number; y: number; width: number; height: number } | null + /** Whether the window was maximized when it was last closed. */ + windowMaximized?: boolean + /** Saved bounds for the pop-out dashboard window so it restores to its last + * position/size. Independent of the main window's bounds. */ + dashboardPopoutBounds?: { x: number; y: number; width: number; height: number } | null + /** One-shot flag: 'recent' once meant the smart sort (v1→v2 rename), migrated to 'smart' once so the new last-activity 'recent' isn't re-clobbered. */ + _sortBySmartMigrated?: boolean + /** LEGACY inline-agents flag, stamped unconditionally every load so it can't gate migration; kept only for rollback forward-compat (real gate: _inlineAgentsDefaultedForAllUsers). */ + _inlineAgentsDefaultedForExperiment?: boolean + /** One-shot flag for the inline-agents default-on rollout; distinct from _inlineAgentsDefaultedForExperiment, which was stamped every load and is permanently dirty. */ + _inlineAgentsDefaultedForAllUsers?: boolean + /** One-shot migration flag for split-out card properties, set once so later deliberate unchecks of Linear issue/Ports stick across restarts. */ + _expandedWorktreeCardPropertiesDefaulted?: boolean + /** One-shot backfill flag for 'jira-issue', which joined the defaults after the expansion migration had already stamped upgraded profiles. */ + _jiraIssueWorktreeCardPropertyDefaulted?: boolean + /** totalAgentsSpawned snapshot at first sighting of the current app version, so the nag counts agents since last update (not from zero). */ + starNagBaselineAgents?: number | null + /** App version that set the current baseline; a version change re-captures the baseline on next spawn, restarting the nag countdown. */ + starNagAppVersion?: string | null + /** Next agents-since-baseline threshold that fires the star-nag; starts at 35, doubles per dismissal without starring. */ + starNagNextThreshold?: number + /** Once the user has starred Orca (any entry point), permanently suppress the nag. */ + starNagCompleted?: boolean + /** Timestamp until which nonterminal dismissals suppress threshold prompts (force-show bypasses for dev/testing). */ + starNagDeferredUntil?: number | null + /** App version that consumed the first value-moment ask; main-owned so remote/web clients can't spoof the once-per-version cap. */ + starNagAgentValueMomentAppVersion?: string | null + trustedOrcaHooks?: PersistedTrustedOrcaHooks + setupScriptPromptDismissedRepoIds?: string[] + /** Pet overlay visibility, separate from the experimentalPet settings flag so "Hide pet" is a reversible dismiss; absent = true. */ + petVisible?: boolean + /** Active pet id (bundled id or custom UUID); unknown ids fall back to the default on read so a removed custom pet doesn't blank the overlay. */ + petId?: string + /** Metadata index for user-uploaded pet images; bytes live under legacy userData/sidekicks/custom/. */ + customPets?: CustomPet[] + /** Pet overlay size in CSS pixels (square); clamped to [PET_SIZE_MIN, PET_SIZE_MAX] on read. */ + petSize?: number + /** Legacy keys from before the sidekick -> pet rename; read only during migration, new writes use pet* above. */ + sidekickVisible?: boolean + sidekickId?: string + customSidekicks?: CustomPet[] + sidekickSize?: number + /** Page-position state for Tasks: only transient tabs/searches (source/repo/team/project selections use their own settings paths). */ + taskResumeState?: TaskResumeState + workspaceCleanup?: WorkspaceCleanupUIState + /** Feature tips already surfaced; startup opens the tips modal only when a current tip id is missing here. */ + featureTipsSeenIds?: FeatureTipId[] + /** Feature ids the user has actually used; education surfaces skip teaching already-discovered features. */ + featureInteractions?: FeatureInteractionState + /** Contextual tours already surfaced; unknown ids ignored on hydration for downgrade/upgrade forward-compat. */ + contextualToursSeenIds?: ContextualTourId[] + /** Whether this profile may receive automatic contextual tours; missing = renderer hasn't classified the profile yet. */ + contextualToursAutoEligible?: boolean +} + +export const PET_SIZE_MIN = 60 +export const PET_SIZE_MAX = 360 +export const PET_SIZE_DEFAULT = 180 + +/** User-uploaded pet image metadata; renderer fetches bytes from main via pet:read (id, fileName), never learning the on-disk path. */ +export type CustomPet = { + id: string + label: string + fileName: string + /** MIME type for the renderer's Blob Content-Type — esp. image/svg+xml, which browsers won't render from a misdeclared blob URL. */ + mimeType: string + /** Storage layout: `image` = legacy flat file `custom/.`; `bundle` = `.codex-pet` expanded into `custom//`; absent = legacy `image`. */ + kind?: 'image' | 'bundle' + /** Sprite-sheet metadata; present iff from a `.codex-pet` bundle with a manifest frame layout. Dims derived in main so the renderer needn't probe the image. */ + sprite?: { + frameWidth: number + frameHeight: number + columns: number + rows: number + sheetWidth: number + sheetHeight: number + fps: number + defaultAnimation?: string + animations?: Record + } + /** Manifest-declared fps kept even when frames are auto-detected, so playback honors the bundle's speed instead of a hardcoded 8 fps. */ + spriteFps?: number +} + +/** One animation strip in a sprite sheet: `row` = 0-based y-index, `frames` = consecutive cells played left-to-right. */ +export type SpriteAnimation = { + row: number + frames: number + /** Per-frame holds in ms (length === frames). Absent means uniform sheet fps. */ + frameDurationsMs?: number[] +} + +export type PersistedTrustedOrcaHookEntry = { + contentHash: string + approvedAt: number +} + +export type PersistedTrustedOrcaHookRepo = { + all?: { + approvedAt: number + } + setup?: PersistedTrustedOrcaHookEntry + archive?: PersistedTrustedOrcaHookEntry + issueCommand?: PersistedTrustedOrcaHookEntry + vmRecipe?: PersistedTrustedOrcaHookEntry +} + +export type PersistedTrustedOrcaHooks = Record + +export type LegacyPaneKeyAliasEntry = { + ptyId: string + /** Physical pane key retained by the live process; name is persisted for compatibility (UUID keys after detach). */ + legacyPaneKey: string + /** Current logical owner pane key. May belong to another tab after detach. */ + stablePaneKey: string + updatedAt: number +} + +/** Last tab selection a paired client made in a worktree; restores phone navigation across host restarts. */ +export type PersistedMobileClientTabSelection = { + activeTabId: string | null + activeGroupId: string | null + activeTabIdByGroupId: Readonly> +} + +/** deviceId → worktreeId → selection. */ +export type PersistedMobileClientTabSelections = Record< + string, + Record +> + +// ─── Persistence shape ────────────────────────────────────────────── +export type PersistedState = { + schemaVersion: number + repos: Repo[] + projects: Project[] + projectHostSetups: ProjectHostSetup[] + projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] + /** Sparse-checkout presets keyed by repoId. */ + sparsePresetsByRepo: Record + /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */ + mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections + worktreeMeta: Record + worktreeLineageById: Record + workspaceLineageByChildKey: Record + settings: GlobalSettings + ui: PersistedUIState + githubCache: { + pr: Record + issue: Record + } + /** Legacy single-blob session, kept as the canonical 'local' host partition so an app downgrade still reads its workspace. */ + workspaceSession: WorkspaceSessionState + /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */ + workspaceSessionsByHostId?: Partial> + sshTargets: SshTarget[] + /** SSH config aliases the user deleted; suppresses re-import from ~/.ssh/config so a deleted host doesn't reappear. */ + deletedSshConfigAliases: string[] + /** Identity records for removed SSH targets so a re-added host can re-adopt workspaces orphaned on the old target id. */ + removedSshTargetTombstones?: RemovedSshTargetTombstone[] + sshRemotePtyLeases: SshRemotePtyLease[] + /** Main-owned authenticated relay recovery records; never expose through renderer settings APIs. */ + sshPtyConsumerRecoveries?: SshPtyConsumerRecovery[] + /** Live local Claude daemon session ids; seeds the live-PTY gate so early OAuth refresh can't rotate the single-use refresh token out from under a running daemon. */ + claudeLivePtySessionIds?: string[] + migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] + automations: Automation[] + automationRuns: AutomationRun[] + onboarding: OnboardingState + /** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */ + featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState + /** Main-owned reset mutation journal. Never expose this through renderer settings APIs. */ + codexResetCreditAttemptLedger?: CodexResetCreditAttemptLedger +} + +// ─── Filesystem ───────────────────────────────────────────── +export type FilesystemPathFlavor = 'posix' | 'win32' + +export type DirEntry = { + name: string + isDirectory: boolean + isSymlink: boolean +} + +export type MarkdownDocument = { + filePath: string + relativePath: string + basename: string + name: string +} + +// ─── Filesystem watcher ───────────────────────────────────── +export type FsChangeEvent = { + kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow' + absolutePath: string + oldAbsolutePath?: string + isDirectory?: boolean +} + +export type FsChangedPayload = { + worktreePath: string + events: FsChangeEvent[] +} + +// ─── Git Status ───────────────────────────────────────────── +// Re-exported from git-status-types.ts so mobile shares the wire contract without this desktop aggregate. + +export type GitBranchChangeEntry = { + path: string + status: GitBranchChangeStatus + oldPath?: string + added?: number + removed?: number +} + +export type GitBranchCompareSummary = { + baseRef: string + baseOid: string | null + compareRef: string + headOid: string | null + mergeBase: string | null + changedFiles: number + commitsAhead?: number + status: 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'loading' | 'error' + errorMessage?: string +} + +export type GitBranchCompareResult = { + summary: GitBranchCompareSummary + entries: GitBranchChangeEntry[] +} + +export type GitCommitCompareSummary = { + commitOid: string + parentOid: string | null + compareRef: string + baseRef: string + changedFiles: number + status: 'ready' | 'invalid-commit' | 'error' + errorMessage?: string +} + +export type GitCommitCompareResult = { + summary: GitCommitCompareSummary + entries: GitBranchChangeEntry[] +} + +export type GitDiffTextResult = { + kind: 'text' + originalContent: string + modifiedContent: string + originalIsBinary: false + modifiedIsBinary: false + largeDiffRenderLimit?: LargeDiffRenderLimit +} + +export type GitDiffBinaryResult = { + kind: 'binary' + originalContent: string + modifiedContent: string + /** Legacy flag used by the renderer for any binary format it can preview, including PDFs. */ + isImage?: boolean + /** MIME type for binary preview rendering, e.g. "image/png" or "application/pdf" */ + mimeType?: string + /** True only for a proven deletion — distinct from an empty modified side caused by a read failure or size cap. */ + modifiedDeleted?: boolean +} & ( + | { originalIsBinary: true; modifiedIsBinary: boolean } + | { originalIsBinary: boolean; modifiedIsBinary: true } +) + +export type GitDiffResult = GitDiffTextResult | GitDiffBinaryResult + +// ─── Search ───────────────────────────────────────────── +export type SearchMatch = { + line: number + column: number + matchLength: number + lineContent: string + displayColumn?: number + displayMatchLength?: number +} + +export type SearchFileResult = { + filePath: string + relativePath: string + matches: SearchMatch[] + matchCount?: number +} + +export type SearchResult = { + files: SearchFileResult[] + totalMatches: number + truncated: boolean +} + +export type SearchOptions = { + query: string + rootPath: string + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults?: number +} + +// ─── Stats ────────────────────────────────────────────────────────── + +export type StatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + // Sourced from aggregates, not the event log, so it survives event trimming. + firstEventAt: number | null // timestamp of first-ever event, for "tracking since..." +} + +// ─── Memory dashboard ────────────────────────────────────────────── + +/** cpu is percent of a single core — can exceed 100 on multi-core. memory is in bytes. */ +export type UsageValues = { + cpu: number + memory: number +} + +export type ProcessMemoryMetric = 'rss' | 'working-set' + +export type HostAvailableMemorySource = 'memory-pressure' | 'proc-meminfo' | 'free-memory' + +/** The top-level cpu/memory are the sum of main + renderer + other. */ +export type AppMemory = UsageValues & { + main: UsageValues + renderer: UsageValues + other: UsageValues + /** Oldest-first memory samples (bytes) for the whole Orca app; empty before the first snapshot. */ + history: number[] +} + +export type SessionMemory = UsageValues & { + sessionId: string + paneKey: string | null + pid: number +} + +/** The top-level cpu/memory are the sum of sessions. */ +export type WorktreeMemory = UsageValues & { + worktreeId: string + worktreeName: string + repoId: string + repoName: string + sessions: SessionMemory[] + /** Oldest-first memory samples (bytes) for this worktree's tracked subtrees. */ + history: number[] +} + +export type HostMemory = { + totalMemory: number + /** Immediately free memory reported by Node's host API. */ + freeMemory: number + /** Memory available without material pressure, or freeMemory when unavailable. */ + availableMemory: number + availableMemorySource: HostAvailableMemorySource + /** totalMemory - availableMemory. */ + usedMemory: number + memoryUsagePercent: number + cpuCoreCount: number + loadAverage1m: number +} + +export type MemorySnapshot = { + app: AppMemory + worktrees: WorktreeMemory[] + host: HostMemory + /** Per-process byte metric used by app, session, worktree, history, and totalMemory values. */ + processMemoryMetric: ProcessMemoryMetric + /** Sum of app + all tracked worktree sessions. Percent of a single core, so may exceed 100 on multi-core machines. */ + totalCpu: number + /** Sum of per-process samples. Shared pages may repeat, so this can exceed host.totalMemory. */ + totalMemory: number + collectedAt: number +} diff --git a/src/shared/workspace-session-schema.sleeping-agent.test.ts b/src/shared/workspace-session-schema.sleeping-agent.test.ts index 0f34528bcb5..03e7c5cccf6 100644 --- a/src/shared/workspace-session-schema.sleeping-agent.test.ts +++ b/src/shared/workspace-session-schema.sleeping-agent.test.ts @@ -559,6 +559,98 @@ describe('parseWorkspaceSession sleeping agents', () => { } }) + it('back-fills requestedAgent and baseAgent for legacy records that carry only agent', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] + expect(record?.baseAgent).toBe('codex') + expect(record?.requestedAgent).toBe('codex') + } + }) + + it('round-trips a custom-id requestedAgent alongside its resumable baseAgent', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'claude', + requestedAgent: 'custom-agent:claude:11111111-1111-4111-8111-111111111111', + baseAgent: 'claude', + providerSession: { key: 'session_id', id: 'claude-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] + expect(record?.requestedAgent).toBe( + 'custom-agent:claude:11111111-1111-4111-8111-111111111111' + ) + expect(record?.baseAgent).toBe('claude') + } + }) + + it('drops an unsafe requestedAgent to the base without dropping the record', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + requestedAgent: 'bad\u0000id', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] + expect(record?.agent).toBe('codex') + expect(record?.requestedAgent).toBe('codex') + } + }) + it('drops sleeping agent records whose embedded pane key differs from the map key', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 3778fa9c833..66a049b1497 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -21,6 +21,7 @@ import type { WorkspaceSessionState } from './workspace-session-state-types' import { terminalTabIdSchema } from './terminal-tab-id-schema' import { terminalSurfaceTombstoneSchema } from './terminal-surface-tombstone-schema' import { parseExecutionHostId, type ExecutionHostId } from './execution-host' +import { persistedLaunchNoticeStateSchema } from './agent-launch-notice-schema' import { isTuiAgent } from './tui-agent-config' import { isWorkspaceKey } from './workspace-scope' import { @@ -109,7 +110,10 @@ const terminalTabSchema = z.object({ launchAgent: z .custom((v) => isTuiAgent(v)) .optional() - .catch(undefined) + .catch(undefined), + // Why: host-owned launch notices ride with the tab; `.catch(undefined)` keeps + // a malformed notice from failing the whole-session parse. + launchNotices: persistedLaunchNoticeStateSchema.optional().catch(undefined) }) // ─── Unified tab model ────────────────────────────────────────────── diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index 4ee620efa82..b1ec8b14594 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -6,6 +6,7 @@ import { } from './agent-session-resume' import { isValidTerminalTabId } from './terminal-tab-id' import { salvagingRecord } from './zod-salvage' +import type { TuiAgent } from './types' const terminalTabIdSchema = z .string() @@ -82,12 +83,28 @@ export const sleepingAgentLaunchConfigSchema = z.preprocess((raw) => { return parsed.success ? parsed.data : undefined }, sleepingAgentLaunchConfigBaseSchema.optional()) +// The originally requested identity: a built-in base or a custom id +// ('custom-agent::'). Kept lenient — an unparseable value drops +// only the field (the read-side falls back to `agent`), never the whole record. +const requestedAgentSchema = z.preprocess( + (raw) => + typeof raw === 'string' && raw.length > 0 && raw.length <= 256 && !hasUnsafeLaunchEnvChars(raw) + ? raw + : undefined, + z.string().optional() +) + const sleepingAgentSessionRecordSchema = z .object({ paneKey: z.string().refine((value) => value.length > 0), tabId: terminalTabIdSchema.optional(), worktreeId: z.string().min(1), agent: z.enum(RESUMABLE_TUI_AGENTS), + // The requested identity resolved to its resumable base; the ownership key and + // provider resume argv key on this, never on `requestedAgent`. Optional during + // the additive migration window — the transform below back-fills legacy records. + requestedAgent: requestedAgentSchema, + baseAgent: z.enum(RESUMABLE_TUI_AGENTS).optional(), providerSession: agentProviderSessionSchema, prompt: z.string(), state: z.enum(['working', 'blocked', 'waiting', 'done']), @@ -108,6 +125,17 @@ const sleepingAgentSessionRecordSchema = z // its persisted transcript path and agents must use their supported key. { message: 'provider session is not resumable for this agent', path: ['providerSession'] } ) + // Deterministic on-read migration: a legacy record carries only `agent`, which + // is already its resumable base, so back-fill both identity fields from it. New + // records persist their own receipt-derived requestedAgent/baseAgent. + .transform((record) => ({ + ...record, + baseAgent: record.baseAgent ?? record.agent, + // Cast: the persisted string is validated leniently above and re-validated at + // read time (resolveTuiAgentBaseAgent returns null for anything unresolvable), + // so a non-conforming id degrades to the base rather than failing the parse. + requestedAgent: (record.requestedAgent ?? record.agent) as TuiAgent + })) export const sleepingAgentSessionsByPaneKeySchema = salvagingRecord( z.string().refine((paneKey) => !isUnsafeObjectKey(paneKey)), diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 5878b70a1b9..4653d947218 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -72,4 +72,55 @@ describe('parseWorkspaceSession terminal fields', () => { expect(result.value.tabsByWorktree.wt).toEqual([]) } }) + + it('round-trips host-owned launch notices and drops malformed notice state', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + launchNotices: { + launchToken: 'tok-1', + notices: [ + { code: 'disabled_custom_fallback', label: 'My Claude', baseAgent: 'claude' } + ] + } + }, + { + id: 'tab2', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1, + // Malformed: missing launchToken. `.catch(undefined)` must drop only + // this field, not fail the whole-session parse. + launchNotices: { notices: [{ code: 'not_a_code', label: 'x' }] } + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt[0].launchNotices).toEqual({ + launchToken: 'tok-1', + notices: [{ code: 'disabled_custom_fallback', label: 'My Claude', baseAgent: 'claude' }] + }) + expect(result.value.tabsByWorktree.wt[1].launchNotices).toBeUndefined() + } + }) }) diff --git a/src/shared/worktree/launch-types.ts b/src/shared/worktree/launch-types.ts index 5fc9f18d397..fb458c13fc6 100644 --- a/src/shared/worktree/launch-types.ts +++ b/src/shared/worktree/launch-types.ts @@ -21,7 +21,12 @@ export type WorktreeStartupLaunch = { launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' startupCommandDelivery?: StartupCommandDelivery - telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } + telemetry?: { + agent_kind: AgentKind + launch_source: LaunchSource + request_kind: RequestKind + used_custom_agent?: boolean + } } export type WorktreeDefaultTabsLaunch = { diff --git a/src/shared/worktree/meta-types.ts b/src/shared/worktree/meta-types.ts index 1f49291fce8..d983acbef6b 100644 --- a/src/shared/worktree/meta-types.ts +++ b/src/shared/worktree/meta-types.ts @@ -12,6 +12,13 @@ import type { import type { TuiAgent } from '../tui-agent' import type { OrcaWorkspaceLayout } from '../global-settings-types' import type { DiffComment, MobileDiffReviewState } from '../diff-comment-types' +import type { PersistedAgentLaunchFailure } from '../agent-launch-contract' + +export type PendingAgentLaunch = { + operationId: string + requestedAgent: TuiAgent + priorFailureId?: string +} // ─── Worktree metadata (persisted user-authored fields only) ───────── export type WorktreeMeta = { @@ -65,6 +72,8 @@ export type WorktreeMeta = { pendingFirstAgentMessageRename?: boolean /** See {@link Worktree.firstAgentMessageRenameError}. */ firstAgentMessageRenameError?: string | null + pendingAgentLaunch?: PendingAgentLaunch + agentLaunchFailure?: PersistedAgentLaunchFailure sparseDirectories?: string[] sparseBaseRef?: string sparsePresetId?: string diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index 3e5c05a65bc..cde624bf9fb 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -5,7 +5,8 @@ import type { TuiAgent } from '../tui-agent' import type { DiffComment, MobileDiffReviewState } from '../diff-comment-types' import type { EphemeralVmCheckoutMode } from '../orca-yaml-hook-types' import type { BuiltInWorktreeVisibilitySourceId } from '../repo-types' -import type { WorktreeIdentity } from './identity' +import type { PersistedAgentLaunchFailure } from '../agent-launch-contract' +import type { PendingAgentLaunch } from './meta-types' export type WorkspaceLinkedItem = { provider: 'github' | 'gitlab' | 'linear' | 'jira' @@ -61,8 +62,6 @@ export type WorkspaceStatusDefinition = { export type Worktree = { id: string // `${repoId}::${path}` instanceId?: string - /** Immutable host/instance identity. Optional while legacy rows migrate. */ - identity?: WorktreeIdentity repoId: string /** Durable project identity. Optional while legacy repo-only workspaces migrate. */ projectId?: string @@ -77,13 +76,9 @@ export type Worktree = { /** Checkout ownership for a recipe-provisioned main workspace. */ ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode displayName: string - /** Projection of persisted display-name provenance. */ - displayNameMode?: 'fixed' | 'automatic' comment: string linkedIssue: number | null linkedPR: number | null - /** GitHub PR hidden from branch discovery after an explicit unlink. */ - suppressedGitHubPR?: number | null linkedLinearIssue: string | null linkedLinearIssueWorkspaceId?: string | null linkedLinearIssueOrganizationUrlKey?: string | null @@ -141,6 +136,8 @@ export type Worktree = { mobileDiffReview?: MobileDiffReviewState automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance + pendingAgentLaunch?: PendingAgentLaunch + agentLaunchFailure?: PersistedAgentLaunchFailure } & GitWorktreeInfo /** Provenance for workspaces created through `orca worktree create`. Absent on