Rebase custom-agents onto main (1/4): agent-launch host + shared/CLI

Host-owned agent-launch subsystem, orchestration U6 identity/forget,
resolver #7862 Windows-shell + Hermes native-query parity, shared launch
contracts, preload/relay/CLI surfaces. One rebase landing split for review;
only the branch tip is expected to build/test green.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-09-01 03:51:47 -07:00
co-authored by Orca
parent f176e49478
commit d99bf4f255
293 changed files with 36578 additions and 1087 deletions
+66
View File
@@ -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<string, string | boolean>) =>
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<string, string | boolean>([
['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<string, string | boolean>([['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<string, string | boolean>([
['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()
@@ -41,6 +41,28 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record<string, CommandHandler> = {
export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record<string, CommandHandler> = {
'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<string, CommandH
})
},
'orchestration dispatch-forget': async ({ flags, client, json }) => {
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',
@@ -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<string, string | boolean>
function flags(entries: Record<string, string | boolean>): Flags {
return new Map(Object.entries(entries))
}
function envelope(
result: RuntimeWorktreeCreateResult
): RuntimeRpcSuccess<RuntimeWorktreeCreateResult> {
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 <id> 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)
})
})
@@ -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<string, string | boolean>
/** 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 <id> 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<AgentLaunchFailureCode, string> = {
unknown_agent: 'the agent no longer exists',
no_agent_selected: 'no agent is selected — set a default or pass --agent <id>',
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<AgentLaunchRequestError['code'], string> = {
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<RuntimeWorktreeCreateResult>,
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<RuntimeWorktreeCreateResult>,
created: CreatedRuntimeWorktreeCreateResult,
source: AgentLaunchSource | undefined,
json: boolean
): void {
const createdResponse: RuntimeRpcSuccess<CreatedRuntimeWorktreeCreateResult> = {
...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
}
+5 -2
View File
@@ -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
}
+21 -32
View File
@@ -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, string | boolean>): 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<string, string | boolean>
): 'run' | 'skip' | 'inherit' | undefined {
@@ -206,7 +196,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
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<string, CommandHandler> = {
}
}
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<RuntimeWorktreeCreateResult>('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<RuntimeWorktreeCreateResult>('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<string, CommandHandler> = {
// 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)
+2 -2
View File
@@ -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 <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent [<id>]] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--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 <id> 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.',
+13 -2
View File
@@ -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 <task_id> [--preamble] [--from <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from']
'orca orchestration dispatch-show --task <task_id> [--preamble] [--raw] [--from <handle>] [--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 <task_id> [--expected-failure-id <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'expected-failure-id'],
notes: [
'The task returns to blocked; retry with: orca orchestration task-update --id <task_id> --status ready.'
]
},
{
path: ['orchestration', 'ask'],
@@ -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: []
}
}
@@ -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<GlobalSettings>
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<string, string> = Object.create(null) as Record<string, string>
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<Record<TuiAgent, string>>
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
discoveredModelsByAgent?: Partial<Record<TuiAgent, unknown>>
discoveredModelsByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, unknown>>>>
}
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<GlobalSettings> {
const patch: Partial<GlobalSettings> = {}
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
}
@@ -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<GlobalSettings>
customId: CustomTuiAgentId
} {
const custom = customAgent({
id: customId('codex'),
baseAgent: 'codex',
label: 'Prod Codex',
args: '--model o3'
})
const v0Settings: Partial<GlobalSettings> = {
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<GlobalSettings>, 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<GlobalSettings> = { ...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<GlobalSettings> = { ...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<GlobalSettings>
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<GlobalSettings> = {
...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<GlobalSettings>
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<GlobalSettings>
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()
})
})
@@ -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<GlobalSettings> = {
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<GlobalSettings> = {
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: []
}
}
@@ -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> = {}): CustomTuiAgent {
return {
id: customId('codex'),
baseAgent: 'codex',
label: 'My Codex',
args: '',
env: {},
syncEnv: false,
...overrides
}
}
function draft(overrides: Partial<CustomAgentDraft> = {}): CustomAgentDraft {
return {
label: 'New Agent',
commandOverride: null,
args: '',
env: {},
syncEnv: false,
...overrides
}
}
function settingsWith(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
return {
defaultTuiAgent: 'auto',
disabledTuiAgents: [],
customTuiAgents: [],
deletedCustomTuiAgents: [],
agentCatalogRevision: 5,
agentCmdOverrides: {},
...overrides
} as GlobalSettings
}
function apply(
overrides: Partial<ApplyAgentCatalogMutationArgs> & {
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<string, string> }),
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<string, string> = {}
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<string, string>
}
}
})
expect(protoKey).toMatchObject({ ok: false, reason: 'prototype_key' })
})
})
@@ -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<CustomTuiAgentId, CustomTuiAgent> {
const map = new Map<CustomTuiAgentId, CustomTuiAgent>()
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<CustomTuiAgentId, CustomTuiAgent> {
const { catalog } = normalizeAgentCatalog({
customTuiAgents: settings.customTuiAgents,
deletedCustomTuiAgents: settings.deletedCustomTuiAgents,
disabledTuiAgents: settings.disabledTuiAgents,
defaultTuiAgent: settings.defaultTuiAgent
})
return definitionsEqualById(catalog.liveCustomAgents)
}
@@ -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)
})
})
@@ -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<AgentTombstoneReferenceIndex>()
/** §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<unknown>
): void {
if (orchestrationScannerRegistered.has(index)) {
return
}
orchestrationScannerRegistered.add(index)
index.register({
owner: 'orchestration',
scan: () => {
try {
return { ok: true, referencedIds: [...referencedRequestedAgents()] }
} catch {
return { ok: false }
}
}
})
}
@@ -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> = {}): CustomTuiAgent {
return {
id: customId('codex'),
baseAgent: 'codex',
label: 'My Codex',
args: '',
env: {},
syncEnv: false,
...overrides
}
}
function settingsWith(overrides: Partial<GlobalSettings> = {}): 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'])
})
})
@@ -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))
}
@@ -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> = {}): CustomTuiAgent {
return {
id: customId('codex'),
baseAgent: 'codex',
label: 'My Codex',
args: '',
env: {},
syncEnv: false,
...overrides
}
}
function draft(overrides: Partial<CustomAgentDraft> = {}): CustomAgentDraft {
return {
label: 'New Agent',
commandOverride: null,
args: '',
env: {},
syncEnv: false,
...overrides
}
}
function settingsWith(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
return {
defaultTuiAgent: 'auto',
disabledTuiAgents: [],
customTuiAgents: [],
deletedCustomTuiAgents: [],
agentCatalogRevision: 5,
agentCmdOverrides: {},
...overrides
} as GlobalSettings
}
function apply(
overrides: Partial<ApplyAgentCatalogMutationArgs> & {
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()
})
})
@@ -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<string, string>()
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<CorruptCatalogRow, (typeof rows)[number]>()
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<number, CustomTuiAgent | null>()
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: []
}
}
@@ -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)
})
})
@@ -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<GlobalSettings>
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<GlobalSettings> | 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<GlobalSettings> = {}
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<GlobalSettings> = {
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 }
}
@@ -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> = {}): 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<string, WorktreeMeta>
failAutomationScan?: boolean
failWorktreeScan?: boolean
}
function makeStoreStub(state: StoreStubState): Store {
const stub = {
getSettings: () => state.settings,
updateSettings: (updates: Partial<GlobalSettings>) => {
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> = {}): 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<StoreStubState>): {
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<StoreStubState> = {}): 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)
}
})
})
@@ -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<typeof buildAgentCatalogSnapshot> {
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<string>()
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<string, unknown>
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<string, string>) } as Record<string, string>)
: {},
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<LocalAgentReferenceSnapshot> {
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() }
}
}
@@ -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])
})
})
@@ -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<string, AdmittedLaunchRecord>()
private readonly countsByPrincipal = new Map<string, number>()
private readonly countsByWorktree = new Map<string, number>()
private readonly reservations = new Map<string, AdmissionPrincipal>()
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<AdmittedLaunchRecord>): 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<void> = Promise.resolve()
runExclusive<T>(critical: () => T): Promise<T> {
const run = this.tail.then(() => critical())
this.tail = run.then(
() => undefined,
() => undefined
)
return run
}
}
@@ -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<string>()
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> | 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> | 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'
@@ -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
}
@@ -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> = {}): 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> = {}
): 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<ResolveAgentLaunchOutcome, { ok: false }>,
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)
})
})
@@ -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<string, AdmittedLaunchRecord>()
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<ExecuteAgentLaunchResult> {
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<CriticalResult>(() =>
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<ExecuteAgentLaunchResult> {
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<CriticalResult>(() =>
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<AgentLaunchFailure | null> {
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<ExecuteAgentLaunchArgs['resolve']>,
original: ResolvedAgentLaunch,
originalFingerprint: string,
admit: () => ReturnType<AgentLaunchAdmissionStore['admit']>
): 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
}
}
}
@@ -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<string, unknown>
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')
}
@@ -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> = {}): 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()
})
})
@@ -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<BuiltInTuiAgent> | null {
if (detected === null || detected === undefined) {
return null
}
const set = new Set<BuiltInTuiAgent>()
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<readonly string[] | null>
/** 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<string | null>
/** 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<AgentLaunchHostState> {
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> => 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<string | null> {
return descriptor.kind === 'local' ? homedir() : null
}
@@ -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([])
})
})
@@ -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<Parameters<typeof buildLegacyResumeReplay>[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)
})
})
@@ -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
}
}
@@ -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<string, string> = {}): Record<string, string> {
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()
})
})
@@ -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<string, string>): 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<string, string>,
shell: 'posix' | 'powershell' | 'cmd'
): Record<string, string> {
const isTeam = isCapturedAgentTeamsConfig(env)
const shimDir = env.ORCA_AGENT_TEAMS_SHIM_DIR?.trim() || null
const delimiter = pathDelimiterForShell(shell)
const cleaned: Record<string, string> = {}
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
}
@@ -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
}
@@ -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: []
})
})
})
@@ -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<string, unknown> {
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.
}
})
}
@@ -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> = {}): 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> = {}
): 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}`)
})
})
@@ -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
}
@@ -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>): 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')
})
})
@@ -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 } : {})
}
})
}
}
@@ -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' }
])
})
})
@@ -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)
}
}
@@ -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<PendingAgentLaunchSnapshot> = {},
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<ReconcileRuntimeDeps> & {
liveTerminalByToken?: (token: string) => LiveTerminalForToken | null
arms?: ReconcileIntentRouterArms
}
): { store: AgentLaunchOperationStore; deps: ReturnType<typeof buildReconcileAgentLaunchDeps> } {
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')
})
})
@@ -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
}
}
@@ -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' })
})
})
@@ -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' }
}
@@ -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' } })
})
})
@@ -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<ReturnType<typeof buildLegacyResumeReplay>, { ok: true }>
): Pick<ResumeLegacyIngest, 'requestedAgent' | 'baseAgent' | 'launchCommand' | 'launchConfig'> {
return {
requestedAgent: replay.requestedAgent,
baseAgent: replay.baseAgent,
launchCommand: replay.launchCommand,
launchConfig: replay.launchConfig
}
}
@@ -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')
})
})
@@ -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<string, string>
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<typeof getSetupRunnerCommandPlatformForPath>
}) => 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
}
}
@@ -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()
})
})
@@ -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<R> = (plan: AgentStartupPlan, launchToken: string) => Promise<R>
export type DispatchAgentLaunchArgs<R> = {
deps: AgentLaunchSpawnDeps
input: AgentLaunchSpawnInput
spawn: LaunchSpawnExecutor<R>
}
export type DispatchAgentLaunchResult<R> =
| { 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<R>(
args: DispatchAgentLaunchArgs<R>
): Promise<DispatchAgentLaunchResult<R>> {
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
}
}
@@ -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> = {}): 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<ResolveAgentLaunchRequest> {
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)
})
})
+222
View File
@@ -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<BuiltInTuiAgent> | null
transportConfidentialityAvailable?: boolean
}
export type AgentLaunchSpawnDeps = {
getSettings: () => GlobalSettings
getCatalogRevision: () => number
boundary: AgentLaunchBoundary
preflight?: (launch: ResolvedAgentLaunch) => Promise<void> | void
prepareEnv?: (launch: ResolvedAgentLaunch) => Promise<void> | 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<Repo, 'sourceControlAi'> | 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<Repo, 'sourceControlAi'> | 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<AgentLaunchSpawnResolution> {
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 } : {})
})
}
@@ -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<CustomTuiAgent> & { id: CustomTuiAgentId }
): CustomTuiAgent {
return {
baseAgent: 'claude',
label: 'My Agent',
args: '',
env: {},
syncEnv: false,
...overrides
}
}
export function tombstone(
overrides: Partial<DeletedCustomTuiAgent> & { 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<Record<BuiltInTuiAgent, string>>
agentDefaultArgs?: Partial<Record<BuiltInTuiAgent, string>>
agentDefaultEnv?: Partial<Record<BuiltInTuiAgent, Record<string, string>>>
}): 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<ResolveAgentLaunchRequest> & {
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<BuiltInTuiAgent> {
return new Set(agents)
}
@@ -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> = {}): 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' } })
})
})
@@ -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<AiVaultSession, 'executionHostPlatform' | 'resumeCommand' | 'resumeLocator' | 'filePath'>
>
/** 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<Record<TuiAgent, string>>
agentDefaultArgs?: Partial<Record<TuiAgent, string>>
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>>
terminalWindowsShell?: string
}
export type VaultResumeStartup = {
command: string
env?: Record<string, string>
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<S extends VaultResumeSession>(
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
}
@@ -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()
})
})
@@ -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<typeof vi.fn>
clearPublicState: ReturnType<typeof vi.fn>
}
function buildDeps(
store: AgentLaunchOperationStore,
overrides: Partial<ForgetUnknownAgentLaunchDeps> = {}
): 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' } })
})
})
@@ -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' }
}
@@ -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> = {}): 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<typeof vi.fn>
failed: ReturnType<typeof vi.fn>
unknown: ReturnType<typeof vi.fn>
} {
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)
})
})
@@ -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)
}
}
}
@@ -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)
})
})
@@ -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> | void
/** Provider env preparation, OUTSIDE the coordinator; same failure mapping. */
prepareEnv?: (launch: ResolvedAgentLaunch) => Promise<void> | 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<PrepareReservedAgentLaunchResult> {
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<ExecuteAgentLaunchResult> {
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
})
}
@@ -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<string, WorktreeRetryInFlight>()
export function findWorktreeRetryInFlight(idempotencyKey: string): WorktreeRetryInFlight | null {
return inFlightByKey.get(idempotencyKey) ?? null
}
export function registerWorktreeRetryInFlight(
idempotencyKey: string,
payloadDigest: string,
promise: Promise<WorktreeRetryAgentLaunchResult>
): 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)
}
@@ -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> = {}
): PersistedAgentLaunchFailure {
return {
code: 'spawn_failed',
requestedAgent: 'claude',
version: 1,
failureId: FAILURE_ID,
intent: 'interactive',
occurredAt: 1,
...overrides
}
}
type Harness = {
deps: WorktreeRetryAgentLaunchDeps
runLaunch: ReturnType<typeof vi.fn>
registerInFlight: ReturnType<typeof vi.fn>
resolveSettled: ReturnType<typeof vi.fn>
store: AgentLaunchOperationStore
requests: AgentLaunchSpawnRequest[]
}
function harness(overrides: Partial<WorktreeRetryAgentLaunchDeps> = {}): 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')
})
})
@@ -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<WorktreeRetryAgentLaunchResult>
}
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<WorktreeRetryAgentLaunchResult>
) => 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<WorktreeRetryAgentLaunchResult>
}
/** 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<WorktreeRetryAgentLaunchResult> {
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
}
@@ -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<typeof vi.fn>
persistPending: ReturnType<typeof vi.fn>
persistFailure: ReturnType<typeof vi.fn>
clearPublicPending: ReturnType<typeof vi.fn>
} {
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<ExecuteAgentLaunchResult>,
extra?: Partial<WorktreeAgentLaunchTransactionParams>
): 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)
})
})
@@ -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<ExecuteAgentLaunchResult>
}
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<WorktreeAgentLaunchOutcome> {
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 }
}
@@ -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> = {}): CustomTuiAgent {
return {
id: customId('codex'),
baseAgent: 'codex',
label: 'My Codex',
args: '',
env: {},
syncEnv: false,
...overrides
}
}
function settingsWith(overrides: Partial<GlobalSettings> = {}): 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> = {}
): 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<NonNullable<GlobalSettings['sourceControlAi']>>
})
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<NonNullable<GlobalSettings['sourceControlAi']>>
})
expect(changed.ok).toBe(true)
const rejected = apply(settings, {
kind: 'source-control-update',
changes: {
actions: { 'commit-message': { agentId: customId('claude', UUID_A) } }
} as Partial<NonNullable<GlobalSettings['sourceControlAi']>>
})
expect(rejected).toMatchObject({ ok: false, owner: 'source-control-recipe' })
})
})
@@ -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<GlobalSettings>
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<SourceControlAiSettings['actions']> = {
...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<SourceControlAiSettings['actions']>[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
}
}
}
}
@@ -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()
})
})
@@ -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
})
}
@@ -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
}
@@ -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: [] })
})
})
@@ -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<string, unknown> {
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.
}
})
}
@@ -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> = {}): 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> = {}): 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<Parameters<AgentSessionRecordStore['resolveVaultSnapshotOwner']>[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<string, string> })
}),
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<StagedLaunchRegistration, 'registeredAt'> => ({
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' })
})
})
@@ -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> = {}): 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<StagedLaunchRegistration, 'registeredAt'>> = {}
): Omit<StagedLaunchRegistration, 'registeredAt'> {
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')
})
})
@@ -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<string, StagedLaunchRegistration>()
// Durable resume records, keyed by ownership key.
private readonly records = new Map<string, HostSessionLaunchRecord>()
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<string, string>()
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<StagedLaunchRegistration, 'registeredAt'>): 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<HostSessionLaunchRecord>): 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)
}
}
}
}
@@ -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()
})
})
@@ -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)}`
}
@@ -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<NodeJS.Platform>([
'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<string, string> {
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<string, Set<string>>()
private readonly ownershipByTranscript = new Map<string, Set<string>>()
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<string, HostSessionLaunchRecord>
): 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<string>()
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<string, HostSessionLaunchRecord>
): VaultSnapshotOwnerResolution {
const targets = this.targetIndex.matching(args.baseAgent, args.scannedExecutionHostId)
if (targets.length === 0) {
return { kind: 'missing' }
}
const found = new Map<string, AgentSessionOwnershipKey>()
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<string>,
preferredWorktreeId: string | null | undefined,
records: ReadonlyMap<string, HostSessionLaunchRecord>
): 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<string, Set<string>>, key: string, ownershipKey: string): void {
const values = index.get(key) ?? new Set<string>()
values.add(ownershipKey)
index.set(key, values)
}
private deleteIndexValue(
index: Map<string, Set<string>>,
key: string,
ownershipKey: string
): void {
const values = index.get(key)
if (!values) {
return
}
values.delete(ownershipKey)
if (values.size === 0) {
index.delete(key)
}
}
}
@@ -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<ResumableTuiAgent, Map<string, number>>()
clear(): void {
this.targetsByBase.clear()
}
add(baseAgent: ResumableTuiAgent, target: IndexedSnapshotTarget): void {
const targets = this.targetsByBase.get(baseAgent) ?? new Map<string, number>()
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
}
@@ -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 })
})
})
@@ -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<AgentReferenceOwnerScanner['owner'], number>()
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<AgentReferenceOwnerKind> }
): 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
}
@@ -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<typeof buildForgetHarness>) {
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<string, WorktreeRetryInFlight>()
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<WorktreeRetryAgentLaunchResult>
): 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()
})
})
@@ -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
})
})
@@ -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<LaunchIntent, { kind: 'background' }>
/** 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 })
)
}
@@ -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
}
@@ -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> = {}
): 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> = {}
): 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<BackgroundAgentLaunchStore['durableState']>
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')
})
})
@@ -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<string, BackgroundAgentLaunchAttempt>()
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<BackgroundAgentLaunchAttempt>): void {
this.attempts.clear()
for (const attempt of attempts) {
this.attempts.set(attempt.attemptId, attempt)
}
}
}
@@ -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<string, string> {
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()
})
})
@@ -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<Record<string, string>>
/** 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<Record<string, string>>) => 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<string, string> {
return Object.create(null) as Record<string, string>
}
function deleteCaseVariants(target: Record<string, string>, 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<string, string>,
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<string, string> {
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
}
@@ -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)
})
})

Some files were not shown because too many files have changed in this diff Show More