Native chat: every agent launches through one composed launch path (#27204)

* test(chat): snapshot every native-chat agent's spawned child as a parity oracle

Captures the exact child each native-chat agent spawns (Claude, Codex, Grok,
OpenCode, OMP, Pi), worktree and floating, fresh and resumed, plus a registered
worker with visuals off, at the real spawn boundary (the process host's spawn)
and through production's own host dependency wiring. The inherited environment
holds every pane, hook, caller, terminal-handle and visuals key a parent Orca
pane could hand down, so the snapshot records today's per-agent differences as
they are. Each case also records the launch's permission posture, which for
Codex (thread policy) and the ACP agents (full access) acts only after spawn.

Each agent also runs fresh and resumed with every launch setting moved off its
default: a Command setting naming another binary, the permission posture
flipped, an environment overlay over inherited and shell keys, and only named
login-shell variables inherited (the fake login shell carries its own PATH and
a variable no case names).

A second test fails when a registered agent has no snapshot, since the oracle
lists its agents by hand.

Test only: the launch-path refactor that follows must leave both tests and the
snapshot file unchanged.

* refactor(chat): one composed launch for every native-chat agent

Every native-chat agent (Claude, Codex, Grok, OpenCode, OMP, Pi) now builds its
child through one composer instead of six hand-written resolvers. Each agent
contributes only its own part (command, args, agent env, keys it must strip);
the shared composer admits the session record, reads the launch directory,
base environment, launch overrides and visuals once each (memoized, in the
agent's original order), sets cwd, and seals the child.

The seal (sealStructuredSessionChild) is the one place that names a child:
session id, structured marker, worker terminal handle, the Orca CLI env, the
spawn token under one shared constant, and the per-launch list of inherited
keys to delete, minus any key the seal itself sets. Claude's delete list now
reaches its SDK spawn after buildClaudeChildProcessEnv's merge.

Behaviour is unchanged: the parity snapshot from the previous commit passes
byte for byte, including each agent's existing differences (Claude and Codex
keep inherited pane and hook env, Pi has no visuals and always full access,
the ACP history read stays unsealed). Admission refusals keep their reasons;
only log-only wording moved to the shared check.

Drops launch dependencies no production caller wires (resolveLaunchEnv,
resolveCodexOverrides) and folds the per-agent env overlay hooks into the
existing resolveAgentLaunchEnv.

* refactor(chat): only the seal names a native-chat child

Follow-ups from review of the composed launch:

- The spawn token is required on every resolveLaunch. The one read that
  starts no child, ACP's restart-recovery history read, now has its own
  path (composeStructuredLaunchRead, deps.readLaunch) that returns the
  agent's own env unsealed, as before. Agent parts receive the session
  identity only, never the token.
- structuredSessionChildIdentityEnv is gone; the seal is the only way to
  build a child's identity env, and tests go through it.
- A launch's environment is the runtime's resolveAgentEnvironment, so the
  base-plus-overlay rule exists once.
- Claude's connection test now covers a non-empty envToDelete applied
  after the inherited env is merged.

No change to any spawned child: the parity snapshot is unchanged.

* ci: run the login-shell contract job for the renamed child-env seal

The seal moved from structured-session-child-identity-env to
structured-session-child-env, but the PR job classifier still listed the old
path, so a change to only the seal would skip the live login-shell contract
job. The prefix now covers the seal, its test and its test support (which
the login-shell harness imports).
This commit is contained in:
Brennan Benson
2026-10-11 23:27:04 -07:00
committed by GitHub
parent 5035baac50
commit d3182d4bf1
91 changed files with 4808 additions and 821 deletions
+1 -1
View File
@@ -135,7 +135,7 @@ const SHELL_PREFIXES = [
'src/main/zsh-',
'src/main/runtime/structured-session-cli-login-shell',
'src/main/runtime/structured-session-login-shell-test-harness',
'src/main/runtime/structured-session-child-identity-env',
'src/main/runtime/structured-session-child-env',
'src/renderer/src/components/terminal-pane/fish-color-scheme',
'src/shared/fish-',
'src/shared/pty-reply-echo-shapes',
+2 -1
View File
@@ -294,7 +294,8 @@ describe('per-job path classification', () => {
)
for (const file of [
'src/main/runtime/structured-session-login-shell-test-harness.ts',
'src/main/runtime/structured-session-child-identity-env.ts'
'src/main/runtime/structured-session-child-env.ts',
'src/main/runtime/structured-session-child-env.test-support.ts'
]) {
expectClassification([file], {
shell_contracts: true,
-19
View File
@@ -2,11 +2,6 @@
// speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name.
import { join } from 'node:path'
import {
ORCA_SCRUB_SAFE_LAUNCH_ENV,
ORCA_SCRUB_SAFE_PANE_ENV
} from '../../shared/agent-hook-scrub-safe-env'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys'
import type { AcpDialect } from './acp-dialects/acp-dialect'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect'
@@ -201,17 +196,3 @@ export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [
export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null {
return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null
}
/**
* A pane's identity in the inherited environment would let the agent's own Orca status hooks
* report for this session too; the structured session is its one status producer.
*/
export const ACP_CHILD_ENV_TO_DELETE: readonly string[] = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN',
ORCA_SCRUB_SAFE_PANE_ENV,
ORCA_SCRUB_SAFE_LAUNCH_ENV,
...AGENT_HOOK_RUNTIME_ENV_KEYS
]
+4 -4
View File
@@ -77,7 +77,7 @@ async function windowFor(load: JournalLoad | null, messages: AcpStoredUserMessag
const window = await readAcpRecoveryHistory(
{
spec: { readStoredUserMessages },
resolveLaunch: async () => launch,
readLaunch: async () => launch,
readJournal: () => load
},
identity
@@ -175,7 +175,7 @@ describe('ACP restart recovery from the agent store', () => {
readAcpRecoveryHistory(
{
spec: { readStoredUserMessages },
resolveLaunch: async () => ({ ...launch, resume: null }),
readLaunch: async () => ({ ...launch, resume: null }),
readJournal: () => journal(submission('held', 'x', 'unknown', 1))
},
identity
@@ -189,7 +189,7 @@ describe('ACP restart recovery from the agent store', () => {
readAcpRecoveryHistory(
{
spec: {},
resolveLaunch: async () => launch,
readLaunch: async () => launch,
readJournal: () => journal(submission('held', 'x', 'unknown', 1))
},
identity
@@ -207,7 +207,7 @@ describe('ACP restart recovery from the agent store', () => {
throw new Error('database is locked')
}
},
resolveLaunch: async () => launch,
readLaunch: async () => launch,
readJournal: () => journal(submission('held', 'x', 'unknown', 1)),
logger: { warn, error: vi.fn() }
},
+2 -2
View File
@@ -96,7 +96,7 @@ function recoveryWindow(
/** The window for one chat; null for an agent whose store Orca does not read. */
export async function readAcpRecoveryHistory(
input: Pick<AcpStructuredSessionAdapterDeps, 'resolveLaunch' | 'readJournal' | 'logger'> & {
input: Pick<AcpStructuredSessionAdapterDeps, 'readLaunch' | 'readJournal' | 'logger'> & {
spec: Pick<AcpLaunchSpec, 'readStoredUserMessages'>
},
identity: AgentSessionJournalIdentity
@@ -113,7 +113,7 @@ export async function readAcpRecoveryHistory(
if (!load || inDoubt.length === 0) {
return null
}
const launch = await waitForPromiseWithSignal(input.resolveLaunch({ identity }), signal)
const launch = await waitForPromiseWithSignal(input.readLaunch(identity), signal)
if (!launch.resume) {
return null
}
+4 -12
View File
@@ -14,14 +14,9 @@ import {
type StructuredAgentSessionAcquireInput
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan'
import {
providerSpawnedProcessIdentity,
PROVIDER_SPAWN_TOKEN_ENV
} from '../provider-process/provider-spawned-process-identity'
import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env'
import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity'
import { AcpAgentError } from './acp-errors'
import { acpAuthenticationRequired, acpSignInRequiredRefusal } from './acp-turn-failures'
import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs'
import {
ACP_REOPEN_FAILED,
acpReopenTakeover,
@@ -83,7 +78,7 @@ export async function acquireAcpStructuredSession(input: {
}
closedBeforeSpawn()
const launch: AcpStructuredLaunch = await deps
.resolveLaunch({ identity: acquire.identity })
.resolveLaunch({ identity: acquire.identity, spawnToken: acquire.spawnToken })
.catch((error: unknown) => {
throw new AgentSessionPreSpawnError(error)
})
@@ -118,11 +113,8 @@ export async function acquireAcpStructuredSession(input: {
command: launch.command,
args: launch.args,
cwd: launch.cwd,
env: {
...structuredSessionChildIdentityEnv(sessionId, launch.env),
[PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken
},
envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
env: launch.env,
envToDelete: launch.envToDelete
},
{
clientInfo: { name: 'orca', version: '1' },
@@ -170,9 +170,9 @@ export async function openAcpAdapterRig(
const lifecycle: StructuredAgentSessionLifecycleEvent[] = []
const settled: AcpAdapterRig['settled'] = []
const spec = options.spec ?? GROK
const adapter = new AcpStructuredSessionAdapter({
spec,
resolveLaunch: async () => ({
const resolveLaunch: AcpStructuredSessionAdapterDeps['resolveLaunch'] =
options.deps?.resolveLaunch ??
(async () => ({
spec,
command: `/opt/bin/${spec.command}`,
args: spec.args({ fullAccess: false, pluginDir: null }),
@@ -182,7 +182,12 @@ export async function openAcpAdapterRig(
fullAccess: false,
resume: null,
...options.launch
}),
}))
const adapter = new AcpStructuredSessionAdapter({
spec,
resolveLaunch,
// A double starts nothing, so a recovery read sees the launch a start would.
readLaunch: (identity) => resolveLaunch({ identity, spawnToken: 'recovery-read' }),
connect: (launch, connectionOptions) => {
spawned.push('spawn')
const child = new FakeAcpChild(launch, connectionOptions)
@@ -1,7 +1,7 @@
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { expect, it, vi } from 'vitest'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { ACP_LAUNCH_SPECS } from './acp-launch-specs'
import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution'
import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home'
it.each(ACP_LAUNCH_SPECS)(
@@ -18,7 +18,7 @@ import { GROK, GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-ad
import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions'
import { replayJournal } from '../native-chat/agent-session-journal/journal-open'
import { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution'
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import {
attachParams,
openHostRig,
@@ -76,6 +76,12 @@ async function openRestoreRig(code: number, message: string) {
throw new Error('launch resolver not ready')
}
return resolver.resolve(input)
},
readLaunch: (identity) => {
if (!resolver.resolve) {
throw new Error('launch resolver not ready')
}
return resolver.resolve({ identity })
}
}
})
@@ -1,13 +1,11 @@
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery'
import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor'
import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs'
import {
createAcpStructuredLaunchResolver,
type AcpStructuredLaunchResolverDeps
} from './acp-structured-launch-resolution'
import { acpLaunchSpecFor } from './acp-launch-specs'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
const OMP = acpLaunchSpecFor('omp')!
const identity = {
@@ -32,7 +30,7 @@ function resolver(
options: {
launchEnv?: Record<string, string>
fullAccess?: boolean
probeVersion?: AcpStructuredLaunchResolverDeps['probeVersion']
probeVersion?: Parameters<typeof createAcpStructuredLaunchResolver>[1]['probeVersion']
} = {}
) {
return createAcpStructuredLaunchResolver(OMP, {
@@ -65,14 +63,17 @@ describe('OMP ACP launch resolution', () => {
ANTHROPIC_API_KEY: 'sk-test'
}
const launch = await resolver(ompRecord(), { launchEnv: userEnv })({ identity })
// Only an inherited visuals folder: this chat has none.
expect(launch.envToDelete).toEqual([NATIVE_CHAT_VISUALS_DIR_ENV])
// Only the shared pane and hook keys, and an inherited visuals folder: this chat has none.
expect(launch.envToDelete).toEqual([
...STRUCTURED_CHILD_ENV_TO_DELETE,
NATIVE_CHAT_VISUALS_DIR_ENV
])
const child = createProviderSpawnSpec(
{
command: launch.command,
args: launch.args,
env: launch.env,
envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
envToDelete: [...STRUCTURED_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
},
{ ORCA_PANE_KEY: 'tab-1:pane-1' },
'darwin'
@@ -125,7 +126,9 @@ describe('OMP ACP launch resolution', () => {
})
it('asks the version of exactly the binary, folder and environment it spawns', async () => {
const asked: Parameters<NonNullable<AcpStructuredLaunchResolverDeps['probeVersion']>>[0][] = []
const asked: Parameters<
NonNullable<Parameters<typeof createAcpStructuredLaunchResolver>[1]['probeVersion']>
>[0][] = []
const launch = await resolver(ompRecord(), {
probeVersion: async (input, supports) => (asked.push(input), supports('17.0.5'))
})({ identity })
@@ -1,3 +1,4 @@
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
@@ -6,11 +7,8 @@ import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-
import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor'
import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home'
import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment'
import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs'
import {
createAcpStructuredLaunchResolver,
type AcpStructuredLaunchResolverDeps
} from './acp-structured-launch-resolution'
import { acpLaunchSpecFor } from './acp-launch-specs'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
const PROFILE = '123e4567-e89b-42d3-a456-426614174000'
@@ -61,7 +59,7 @@ function resolver(
launchEnv?: Record<string, string>
inheritedEnv?: NodeJS.ProcessEnv
base?: Record<string, string>
probeVersion?: AcpStructuredLaunchResolverDeps['probeVersion']
probeVersion?: Parameters<typeof createAcpStructuredLaunchResolver>[1]['probeVersion']
} = {}
) {
return createAcpStructuredLaunchResolver(OPENCODE, {
@@ -106,7 +104,7 @@ describe('OpenCode ACP launch resolution', () => {
command: launch.command,
args: launch.args,
env: launch.env,
envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
envToDelete: [...STRUCTURED_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
},
{},
'darwin'
@@ -159,7 +157,7 @@ describe('OpenCode ACP launch resolution', () => {
command: launch.command,
args: launch.args,
env: launch.env,
envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
envToDelete: [...STRUCTURED_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
},
inheritedEnv,
'darwin'
@@ -179,7 +177,7 @@ describe('OpenCode ACP launch resolution', () => {
command: launch.command,
args: launch.args,
env: launch.env,
envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
envToDelete: [...STRUCTURED_CHILD_ENV_TO_DELETE, ...launch.envToDelete]
},
inheritedEnv,
'darwin'
@@ -215,7 +213,7 @@ describe('OpenCode ACP launch resolution', () => {
it('refuses a record that pins a single directory instead of an OpenCode account', async () => {
await expect(
resolver(openCodeRecord({ variable: 'XDG_DATA_HOME', path: '/data' }))({ identity })
).rejects.toThrow(/pinned data account/)
).rejects.toThrow(/sessions pin opencode/)
})
it('runs an `opencode` that is stable 2.x', async () => {
@@ -245,7 +243,9 @@ describe('OpenCode ACP launch resolution', () => {
})
it('asks the version of exactly the binary, folder and environment it spawns', async () => {
const asked: Parameters<NonNullable<AcpStructuredLaunchResolverDeps['probeVersion']>>[0][] = []
const asked: Parameters<
NonNullable<Parameters<typeof createAcpStructuredLaunchResolver>[1]['probeVersion']>
>[0][] = []
const launch = await resolver(openCodeRecord(UNMANAGED), {
probeVersion: async (input, supports) => (asked.push(input), supports('1.18.31'))
})({ identity })
@@ -1,3 +1,4 @@
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -17,9 +18,9 @@ import {
spellProviderTimelineKey
} from '../native-chat/agent-session-timeline/provider-timeline-identity'
import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor'
import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs'
import { acpLaunchSpecFor } from './acp-launch-specs'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
import { acpSessionNotRestoredItem } from './acp-session-reopen-failure'
import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution'
const GROK = acpLaunchSpecFor('grok')!
const identity = {
@@ -270,7 +271,7 @@ describe('ACP launch resolution', () => {
it('refuses a record pinned to another host or another agent', async () => {
const remote = grokRecord()
remote.location = { ...remote.location, executionHostId: 'ssh:box' }
await expect(resolver(remote).resolve({ identity })).rejects.toThrow(/run on this runtime/)
await expect(resolver(remote).resolve({ identity })).rejects.toThrow(/run on the local host/)
await expect(resolver(agentSessionRecordFixture()).resolve({ identity })).rejects.toThrow(
/claude session/
)
@@ -295,7 +296,7 @@ describe('Grok status: the structured session is the only producer', () => {
command: 'grok',
args: ['agent', 'stdio'],
env: { GROK_HOME: '/home/user/.grok' },
envToDelete: ACP_CHILD_ENV_TO_DELETE
envToDelete: STRUCTURED_CHILD_ENV_TO_DELETE
},
inherited,
'win32'
@@ -6,14 +6,12 @@
import { delimiter } from 'node:path'
import { homedir } from 'node:os'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
agentSessionProviderHandleChainHead,
agentSessionProviderHandleKey,
type AgentSessionProviderHandleChain
} from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home'
import type { resolveCliCommand } from '../../shared/node-cli-command-resolution'
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
@@ -29,8 +27,8 @@ import {
providerTimelineKeyPart,
spelledProviderTimelineItemKey
} from '../native-chat/agent-session-timeline/provider-timeline-identity'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
import {
NATIVE_CHAT_VISUALS_DIR_ENV,
withNativeChatVisualsEnv
@@ -44,6 +42,7 @@ export type AcpStructuredLaunch = {
command: string
args: string[]
cwd: string
/** The child's sealed environment; a read that starts no child gets the agent's own. */
env: Record<string, string>
/** Keys the child must not inherit from this process's own environment. */
envToDelete: string[]
@@ -62,25 +61,27 @@ export type AcpStructuredLaunch = {
} | null
}
export type AcpStructuredLaunchResolverDeps = {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'pinLaunchDirectory'>
export type AcpStructuredLaunchPartDeps = {
/** The chat's journal as it stands, read without opening it; null when it has none. */
readJournal: (sessionId: string) => JournalLoad | null
resolveWorkspacePath: (workspaceId: string) => Promise<string>
/** The shared base env; re-read per acquisition. */
resolveEnvironment: () => Promise<Record<string, string>>
/** The user's per-agent environment overlay from settings. */
resolveLaunchEnv?: (agent: string) => Record<string, string>
/** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */
resolveFullAccess?: (agent: string) => boolean
/** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */
resolveCommandSettings?: () => StructuredAgentCommandSettings
resolveCommand?: typeof resolveCliCommand
homePath?: string
/** The environment the child process inherits at spawn; this process's own by default. */
inheritedEnv?: NodeJS.ProcessEnv
probeVersion?: typeof probeAgentCliVersion
} & AcpLaunchVisualsDeps
} & Pick<AcpLaunchVisualsDeps, 'logger'>
/** What a launch's binary and environment are read from; a catalog probe reads the same. */
export type AcpLaunchInvocationDeps = {
/** The shared base env; re-read per acquisition. */
resolveEnvironment: () => Promise<Record<string, string>>
/** The user's per-agent environment overlay from settings. */
resolveLaunchEnv?: (agent: string) => Record<string, string>
/** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */
resolveCommandSettings?: () => StructuredAgentCommandSettings
} & Pick<AcpStructuredLaunchPartDeps, 'resolveCommand' | 'homePath' | 'inheritedEnv'>
/** The binary a launch with `env` spawns: the user's Command setting when set, else the stock
* binary from PATH, then the agent's own install directories. A create's version check resolves
@@ -142,16 +143,6 @@ export async function acpLaunchVersionSupported(
return (await probeAcpLaunchVersion(spec, launch, false, probe)).supported
}
export type AcpLaunchInvocationDeps = Pick<
AcpStructuredLaunchResolverDeps,
| 'resolveEnvironment'
| 'resolveLaunchEnv'
| 'resolveCommandSettings'
| 'resolveCommand'
| 'homePath'
| 'inheritedEnv'
>
/** The binary and environment any child of this agent runs under for `accountHome`: a session's
* launch and a catalog probe both resolve here, so neither can reach another account or install. */
export async function resolveAcpLaunchInvocation(
@@ -173,31 +164,28 @@ export async function resolveAcpLaunchInvocation(
return { command, env, envToDelete }
}
export function createAcpStructuredLaunchResolver(
/** An ACP agent's part of a launch: its binary under the pinned account, the release check, the
* visuals skill, its arguments and the provider session to reattach. */
export function acpStructuredLaunchPart(
spec: AcpLaunchSpec,
deps: AcpStructuredLaunchResolverDeps
): (input: { identity: AgentSessionJournalIdentity }) => Promise<AcpStructuredLaunch> {
return async ({ identity }) => {
const record = deps.store.getRecord(identity.sessionId)
if (!record) {
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
}
if (record.provider !== spec.agent) {
throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
}
const { location, accountHome } = record
// A session pinned elsewhere belongs to that host's runtime; starting it here would put a
// second writer on the same provider session.
if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) {
throw new Error(
`${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}`
)
}
const { env, envToDelete, command } = await resolveAcpLaunchInvocation(spec, accountHome, deps)
const cwd = await resolveAgentSessionLaunchDirectory(deps, record)
const visuals = spec.visualsSkill
? ((await deps.prepareVisuals?.(identity.sessionId)) ?? null)
: null
deps: AcpStructuredLaunchPartDeps
): StructuredAgentLaunchPartResolver<AcpStructuredLaunch> {
return async (basis, identity) => {
const { record } = basis
const { env, envToDelete, command } = await resolveAcpLaunchInvocation(
spec,
record.accountHome,
{
resolveEnvironment: basis.baseEnvironment,
resolveLaunchEnv: () => basis.launchEnv(),
resolveCommandSettings: basis.commandSettings,
...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}),
...(deps.homePath ? { homePath: deps.homePath } : {}),
...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {})
}
)
const cwd = await basis.launchDirectory()
const visuals = spec.visualsSkill ? await basis.visuals() : null
// Again at every launch: the binary on PATH may have changed since the chat was created.
const launchVersion = await probeAcpLaunchVersion(
spec,
@@ -220,29 +208,33 @@ export function createAcpStructuredLaunchResolver(
inherited: deps.inheritedEnv ?? process.env
})
: null
// Without visuals, a folder Orca itself inherited (started from a chat) names another chat's.
const childEnv = withNativeChatVisualsEnv({ ...env, ...skill?.env }, skill ? visuals : null)
const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
return {
spec,
command,
args: spec.args({ fullAccess, pluginDir: skill?.pluginDir ?? null }),
cwd,
env: childEnv,
envToDelete: skill ? envToDelete : [...envToDelete, NATIVE_CHAT_VISUALS_DIR_ENV],
fullAccess,
resume: head
? {
sessionId: head.handle.nativeId,
key: agentSessionProviderHandleKey(head.handle),
mayBeUnsaved: () =>
head.origin === 'created' &&
nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId),
unannouncedLosses: () =>
unannouncedLosses(deps.readJournal, identity.sessionId, record.providerHandleChain)
}
: null
launch: {
spec,
command,
args: spec.args({ fullAccess, pluginDir: skill?.pluginDir ?? null }),
fullAccess,
resume: head
? {
sessionId: head.handle.nativeId,
key: agentSessionProviderHandleKey(head.handle),
mayBeUnsaved: () =>
head.origin === 'created' &&
nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId),
unannouncedLosses: () =>
unannouncedLosses(deps.readJournal, identity.sessionId, record.providerHandleChain)
}
: null
},
env: withNativeChatVisualsEnv({ ...env, ...skill?.env }, skill ? visuals : null),
inheritedEnvToDelete: [
...STRUCTURED_CHILD_ENV_TO_DELETE,
...envToDelete,
// Without visuals, a folder Orca itself inherited (started from a chat) names another chat's.
...(skill ? [] : [NATIVE_CHAT_VISUALS_DIR_ENV])
]
}
}
}
@@ -250,7 +242,7 @@ export function createAcpStructuredLaunchResolver(
/** Proof the chat holds no turn of provider session `nativeId`; a journal that does not read whole,
* or does not read at all, proves nothing. */
function nothingExchangedOn(
readJournal: AcpStructuredLaunchResolverDeps['readJournal'],
readJournal: AcpStructuredLaunchPartDeps['readJournal'],
sessionId: string,
nativeId: string
): boolean {
@@ -278,7 +270,7 @@ function nothingExchangedOn(
/** Derived on every start, never stored: a row an attach failure dropped is written by the next
* start that succeeds. A journal that does not read whole proves nothing, so none is written. */
function unannouncedLosses(
readJournal: AcpStructuredLaunchResolverDeps['readJournal'],
readJournal: AcpStructuredLaunchPartDeps['readJournal'],
sessionId: string,
chain: AgentSessionProviderHandleChain
): string[] {
@@ -1,13 +1,10 @@
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery'
import { resolveProviderChildEnv } from '../provider-process/provider-process-launch'
import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs'
import {
createAcpStructuredLaunchResolver,
type AcpStructuredLaunchResolverDeps
} from './acp-structured-launch-resolution'
const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' }
const FOLDER = '/state/native-chat-visuals/0123'
@@ -40,14 +37,14 @@ function resolve(
version?: string
inheritedEnv?: NodeJS.ProcessEnv
launchEnv?: Record<string, string>
deps?: Partial<AcpStructuredLaunchResolverDeps>
deps?: Partial<Parameters<typeof createAcpStructuredLaunchResolver>[1]>
spec?: Partial<AcpLaunchSpec>
} = {}
) {
const spec = { ...acpLaunchSpecFor(agent)!, ...options.spec }
const probeVersion = vi.fn<NonNullable<AcpStructuredLaunchResolverDeps['probeVersion']>>(
async (_input, supports) => supports(options.version ?? '1.0.46')
)
const probeVersion = vi.fn<
NonNullable<Parameters<typeof createAcpStructuredLaunchResolver>[1]['probeVersion']>
>(async (_input, supports) => supports(options.version ?? '1.0.46'))
const prepareVisuals = vi.fn(async () =>
options.visuals === false ? null : { folder: FOLDER, skill: SKILL }
)
@@ -18,7 +18,13 @@ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000
export type AcpStructuredSessionAdapterDeps = {
spec: AcpLaunchSpec
resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise<AcpStructuredLaunch>
/** The child to spawn, sealed with the reservation's token. */
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
spawnToken: string
}) => Promise<AcpStructuredLaunch>
/** The same launch for a read that starts no child (restart recovery): the agent's env, unsealed. */
readLaunch: (identity: AgentSessionJournalIdentity) => Promise<AcpStructuredLaunch>
/** The chat's journal as it stands, read without opening it; null when it has none. */
readJournal?: (sessionId: string) => JournalLoad | null
/** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */
@@ -10,7 +10,7 @@ import {
AgentSessionAcquisitionRootExitObservedError
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect'
import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys'
import {
GROK,
@@ -21,6 +21,8 @@ import {
type AcpAdapterRig
} from './acp-structured-adapter.test-support'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
afterEach(async () => {
await closeProviderTimelineRigs()
@@ -65,10 +67,30 @@ describe('ACP structured session adapter: acquire', () => {
})
it('launches without any pane identity, so the agent own status hooks stay silent', async () => {
const rig = await openAcpAdapterRig()
const record = agentSessionRecordFixture({
sessionId: SESSION,
provider: 'grok',
accountHome: { variable: 'GROK_HOME', path: '/home/work/.grok' }
})
// The launch its registration composes: the shared seal names the child, not this adapter.
const rig = await openAcpAdapterRig({
deps: {
resolveLaunch: createAcpStructuredLaunchResolver(GROK, {
store: { getRecord: () => record, pinLaunchDirectory: async () => record },
resolveWorkspacePath: async () => '/workspace/project',
resolveEnvironment: async () => ({
PATH: '/usr/bin',
ORCA_PANE_KEY: 'tab-1:pane-1',
ORCA_AGENT_HOOK_PORT: '1234'
}),
resolveCommand: () => '/opt/bin/grok',
readJournal: () => null
})
}
})
await rig.acquire()
const launch = rig.child().launch
expect(launch.envToDelete).toEqual(expect.arrayContaining([...ACP_CHILD_ENV_TO_DELETE]))
expect(launch.envToDelete).toEqual(expect.arrayContaining([...STRUCTURED_CHILD_ENV_TO_DELETE]))
expect(launch.envToDelete).toEqual(
expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_PANE', ...AGENT_HOOK_RUNTIME_ENV_KEYS])
)
@@ -1,3 +1,4 @@
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -15,7 +16,6 @@ import { spawnProcess } from '@orca/process-host'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests'
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
// Contract pins for @anthropic-ai/claude-agent-sdk, run against the real SDK
// driving a scripted fake CLI (never the real Claude binary). These tests exist
@@ -8,8 +8,9 @@ import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-ca
import { claudeConfigDirEnvPatch } from './claude-config-dir-pin'
import {
resolveClaudeStructuredInvocation,
type ClaudeStructuredLaunchResolverDeps
type ClaudeStructuredLaunchPartDeps
} from './claude-structured-launch-resolution'
import type { ClaudeEnvDeps } from './claude-structured-child-env'
import type {
AgentModelCatalogProbe,
AgentModelCatalogSuccess
@@ -19,13 +20,11 @@ import {
runAgentModelCatalogListing
} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner'
export type ClaudeModelCatalogProbeDeps = Pick<
ClaudeStructuredLaunchResolverDeps,
'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy'
> & {
/** Test seam; production runs the shared supervised listing. */
runListing?: typeof runAgentModelCatalogListing
}
export type ClaudeModelCatalogProbeDeps = ClaudeEnvDeps &
Pick<ClaudeStructuredLaunchPartDeps, 'resolveAuthPolicy'> & {
/** Test seam; production runs the shared supervised listing. */
runListing?: typeof runAgentModelCatalogListing
}
/**
* Lists models without a live session: one `list_models` control request, under the SAME binary
@@ -221,6 +221,25 @@ describe('Claude stream-json connection', () => {
expect(report.argv).toContain(`--session-id=${SESSION_ID}`)
})
it("strips the launch's inherited keys after Orca's own env is merged in", async () => {
vi.stubEnv('ORCA_PANE_KEY', 'tab-1:pane-1')
vi.stubEnv('ORCA_AGENT_HOOK_PORT', '4242')
vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited')
const scenario = scriptScenario([HOLD_OPEN])
await open({
...launchFor(scenario, { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }),
envToDelete: ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT']
})
await until(() => readReportSafely(scenario), 'the scripted CLI report')
const env = childEnv()
expect(env.ORCA_PANE_KEY).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
// Only the listed keys go: the rest of the inherited env and the launch's own still arrive.
expect(env.ORCA_CONNECTION_MARKER).toBe('inherited')
expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666')
})
it('leaves the default CLI home unpinned so macOS Keychain OAuth keeps working', async () => {
const scenario = scriptScenario([HOLD_OPEN])
await open(launchFor(scenario))
@@ -54,6 +54,15 @@ export type ClaudeStreamJsonLaunch = {
options: ClaudeStructuredSdkOptions
cwd: string
env?: Record<string, string>
/** Removed after the child's env is built over Orca's own, as every provider child's are. */
envToDelete?: readonly string[]
}
function withoutKeys(env: Record<string, string>, keys: readonly string[]): Record<string, string> {
for (const key of keys) {
delete env[key]
}
return env
}
export type ClaudeStreamJsonConnectionHandlers = {
@@ -141,10 +150,13 @@ export async function openClaudeStreamJsonConnection(
// exactly the ambient ANTHROPIC_* auth leak this lane already shipped once.
// Orca's own CLAUDE_CONFIG_DIR is dropped for the same reason the launch drops the
// shell's: the record's pin in `launch.env` must be the only home the child sees.
env: buildClaudeChildProcessEnv(launch.env, {
inheritedEnv: withoutInheritedClaudeConfigDir(process.env),
scrubConfiguredChildSessionStamps: true
}),
env: withoutKeys(
buildClaudeChildProcessEnv(launch.env, {
inheritedEnv: withoutInheritedClaudeConfigDir(process.env),
scrubConfiguredChildSessionStamps: true
}),
launch.envToDelete ?? []
),
pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable,
spawnClaudeCodeProcess: spawner.spawn,
...(handlers.canUseTool ? { canUseTool: handlers.canUseTool } : {}),
@@ -89,7 +89,7 @@ export async function resolveClaudeAcquisitionLaunch(args: {
}
: input.identity
const launch = await deps
.resolveLaunch({ identity: launchIdentity })
.resolveLaunch({ identity: launchIdentity, spawnToken: input.spawnToken })
.catch((error: unknown) => {
throw error instanceof AgentSessionPreSpawnError
? error
@@ -1,7 +1,7 @@
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
const SESSION_ID = 'orca-session-auth'
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
+19 -14
View File
@@ -5,7 +5,6 @@ import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { resolveClaudeCommand } from '../codex-cli/command'
import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin'
import type { ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution'
function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
const next: Record<string, string> = {}
@@ -17,10 +16,15 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Recor
return next
}
export type ClaudeEnvDeps = Pick<
ClaudeStructuredLaunchResolverDeps,
'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv'
>
export type ClaudeEnvDeps = {
resolveCommand?: () => string
resolveEnv?: () =>
| Promise<Record<string, string> | undefined>
| Record<string, string>
| undefined
/** The env the child inherits before auth stripping; absent inherits Orca's own process env. */
resolveInheritedEnv?: () => Promise<Record<string, string>>
}
/** What a child's env is built from, before any auth policy applies to it. */
export type ClaudeChildEnvSources = {
@@ -46,16 +50,17 @@ export async function resolveClaudeChildEnvSources(
// every account, as it does in a terminal.
export function claudeChildEnv(
sources: ClaudeChildEnvSources,
decorateEnv: (env: Record<string, string>) => Record<string, string> = (env) => env
decorateEnv: (env: Record<string, string>) => Record<string, string> = (env) => env,
/** False when the session's seal pairs the CLI runtime itself, after the session's identity. */
{ runtimeOnPath = true }: { runtimeOnPath?: boolean } = {}
): Record<string, string> {
return withCliRuntimeOnPath(
sources.command,
decorateEnv({
...withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform),
...sources.overlay
}),
{ platform: process.platform }
)
const env = decorateEnv({
...withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform),
...sources.overlay
})
return runtimeOnPath
? withCliRuntimeOnPath(sources.command, env, { platform: process.platform })
: env
}
/** The env a `--version` probe runs with: the launch's own env, PATH and shims included, minus the
@@ -1,3 +1,4 @@
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
@@ -10,7 +11,6 @@ import {
type ClaudeProfileRouterSettings
} from '../claude-accounts/claude-profile-router'
import { installClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
const roots: string[] = []
afterEach(() => {
@@ -1,3 +1,4 @@
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
@@ -9,9 +10,7 @@ import {
CLAUDE_DEFAULT_SETTING_SOURCES,
CLAUDE_SESSION_STATE_EVENTS_ENV,
CLAUDE_STRUCTURED_BASE_OPTIONS,
claudeSessionIdForOrcaSession,
createClaudeStructuredLaunchResolver,
type ClaudeStructuredLaunchResolverDeps
claudeSessionIdForOrcaSession
} from './claude-structured-launch-resolution'
import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode'
import { CLAUDE_THINKING_DISPLAY_FLAG, type ClaudeCliFlag } from './claude-cli-flag-support'
@@ -574,7 +573,7 @@ describe('claude structured launch resolution', () => {
describe('readable Claude thinking', () => {
const launchWith = (
cliFlags?: ClaudeStructuredLaunchResolverDeps['cliFlags'],
cliFlags?: Parameters<typeof createClaudeStructuredLaunchResolver>[0]['cliFlags'],
command = '/usr/local/bin/claude',
launchArgs: string[] = []
) =>
@@ -5,11 +5,8 @@ import type {
Options as ClaudeAgentSdkOptions,
PermissionMode
} from '@anthropic-ai/claude-agent-sdk'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provider-handle'
import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env'
import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
import {
claudeChildEnv,
@@ -21,17 +18,12 @@ import {
import { claudeStructuredLaunchArgs } from './claude-structured-launch-args'
import type { ClaudeCliFlagSupport } from './claude-cli-flag-support'
import { resolveClaudeLaunchFlags } from './claude-structured-launch-flags'
import {
withNativeChatVisualsEnv,
type PrepareNativeChatVisuals
} from '../native-chat/native-chat-visuals-delivery'
import { withNativeChatVisualsEnv } from '../native-chat/native-chat-visuals-delivery'
import {
claudeLaunchResumesTranscript,
resolveClaudeStructuredLaunchHome
} from './claude-structured-launch-home'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory'
import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import { claudeSessionIdForOrcaSession } from './claude-structured-session-id'
export { claudeSessionIdForOrcaSession }
@@ -92,7 +84,9 @@ export type ClaudeStructuredLaunch = {
account?: ClaudeStructuredInvocation['account']
options: ClaudeStructuredSdkOptions
cwd: string
/** The child's sealed environment, and the inherited keys it must not keep. */
env?: Record<string, string>
envToDelete?: readonly string[]
claudeConfigDir: string
providerSessionId: string
/** The previous head leaf, carried into the publication link; never a resume argument. */
@@ -104,17 +98,9 @@ export type ClaudeStructuredLaunch = {
continuesChain: boolean
}
export type ClaudeStructuredLaunchResolverDeps = {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'pinLaunchDirectory'>
export type ClaudeStructuredLaunchPartDeps = {
resolveLaunchArgs: () => Promise<string[]> | string[]
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCommand?: () => string
resolveEnv?: () =>
| Promise<Record<string, string> | undefined>
| Record<string, string>
| undefined
/** The env the child inherits before auth stripping; absent inherits Orca's own process env. */
resolveInheritedEnv?: () => Promise<Record<string, string>>
/** Required, so every caller states which login a failed sign-in names rather than inherit a
* guess. Build it with claudeStructuredAuthPolicyForSettings. */
resolveAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
@@ -125,8 +111,6 @@ export type ClaudeStructuredLaunchResolverDeps = {
attachmentDirectory?: string
/** Which version-gated flags this CLI takes. Absent ⇒ none is ever passed. */
cliFlags?: Pick<ClaudeCliFlagSupport, 'supports'>
/** This chat's visuals folder and skill; absent or null ⇒ the chat gets neither. */
prepareVisuals?: PrepareNativeChatVisuals
/** Whether Claude wrote a transcript for this id; defaults to the transcript resolver. */
hasTranscript?: (input: {
providerSessionId: string
@@ -148,47 +132,32 @@ export type ClaudeStructuredInvocation = {
* drift there heals on the next refresh.
*/
export async function resolveClaudeStructuredInvocation(
deps: ClaudeEnvDeps & Pick<ClaudeStructuredLaunchResolverDeps, 'resolveAuthPolicy'>,
deps: ClaudeEnvDeps & Pick<ClaudeStructuredLaunchPartDeps, 'resolveAuthPolicy'>,
decorateEnv: (env: Record<string, string>) => Record<string, string> = (env) => env,
/** Already resolved by a caller that needed them earlier; read again otherwise. */
resolvedSources?: ClaudeChildEnvSources
resolvedSources?: ClaudeChildEnvSources,
options?: Parameters<typeof claudeChildEnv>[2]
): Promise<ClaudeStructuredInvocation> {
const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps))
const auth = await deps.resolveAuthPolicy()
// Why never strip: a shell proxy's key must travel with its ANTHROPIC_BASE_URL, on every account.
return {
command: sources.command,
env: claudeChildEnv(sources, decorateEnv),
env: claudeChildEnv(sources, decorateEnv, options),
account: auth.account
}
}
export function createClaudeStructuredLaunchResolver(
deps: ClaudeStructuredLaunchResolverDeps
): (input: { identity: AgentSessionJournalIdentity }) => Promise<ClaudeStructuredLaunch> {
return async ({ identity }) => {
const record = deps.store.getRecord(identity.sessionId)
if (!record) {
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
}
if (record.provider !== 'claude') {
throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
}
if (
record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
record.location.wslDistro !== null
) {
throw new Error(
`claude structured sessions run on the local host, not ${record.location.executionHostId}`
)
}
/** Claude's part of a launch: its resume decision, flags, permission posture, account home and
* auth, with the account-switch recheck last. */
export function claudeStructuredLaunchPart(
deps: ClaudeStructuredLaunchPartDeps
): StructuredAgentLaunchPartResolver<ClaudeStructuredLaunch> {
return async (basis, identity) => {
const { record } = basis
const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome)
const pinned = CLAUDE_STRUCTURED_AGENT.accountHomeVariable
if (accountHome.variable !== pinned) {
throw new Error(`claude sessions pin ${pinned}, not ${accountHome.variable}`)
}
const router = getClaudeProfileRouter()
// A Claude record's chain holds only Claude handles; the attach admission refuses anything else.
// A Claude record's chain holds only Claude handles; the launch admission refuses anything else.
const active = activeProviderContext(record)
const head = active.head?.handle ?? null
if (
@@ -206,11 +175,21 @@ export function createClaudeStructuredLaunchResolver(
active.pendingClear ? record.providerContextBoundary?.operationId : undefined
)
const continuesChain = head !== null
const cwd = await resolveAgentSessionLaunchDirectory(deps, record)
const sources = await resolveClaudeChildEnvSources(deps)
const cwd = await basis.launchDirectory()
const envDeps: ClaudeEnvDeps & Pick<ClaudeStructuredLaunchPartDeps, 'resolveAuthPolicy'> = {
...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}),
resolveEnv: () => basis.launchEnv(),
resolveInheritedEnv: basis.baseEnvironment,
resolveAuthPolicy: deps.resolveAuthPolicy
}
const sources = await resolveClaudeChildEnvSources(envDeps)
// Asked as soon as the spawn's cwd and PATH are known, so it overlaps what is left to resolve.
const probeLaunch = { command: sources.command, cwd, env: claudeProbeEnv(sources) }
const launchFlags = resolveClaudeLaunchFlags(deps, record.sessionId, probeLaunch)
const launchFlags = resolveClaudeLaunchFlags(
{ ...(deps.cliFlags ? { cliFlags: deps.cliFlags } : {}), prepareVisuals: basis.visuals },
record.sessionId,
probeLaunch
)
const configured = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs())
const { thinkingDisplayArgs, visuals } = await launchFlags
const additionalDirectories = [
@@ -237,48 +216,50 @@ export function createClaudeStructuredLaunchResolver(
const resumedWithoutRouter = router ? undefined : await resumes(accountHome.path)
// Last: it rechecks the account switch, which may have begun during any await above.
const { command, env, account } = await resolveClaudeStructuredInvocation(
deps,
envDeps,
(base) =>
// Every structured session speaks orchestration as itself: its injected id and the Orca CLI.
structuredSessionChildIdentityEnv(
record.sessionId,
withNativeChatVisualsEnv(
{
...base,
// The turn translator relies on Claude's authoritative idle frame when no result arrives.
[CLAUDE_SESSION_STATE_EVENTS_ENV]: '1'
},
visuals?.visuals ?? null
)
withNativeChatVisualsEnv(
{
...base,
// The turn translator relies on Claude's authoritative idle frame when no result arrives.
[CLAUDE_SESSION_STATE_EVENTS_ENV]: '1'
},
visuals?.visuals ?? null
),
sources
sources,
{ runtimeOnPath: false }
)
const launchHome = await resolveClaudeStructuredLaunchHome(router, env, accountHome.path)
const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome))
return {
pathToClaudeCodeExecutable: command,
account,
options: {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
...permission,
...(additionalDirectories.length ? { additionalDirectories } : {}),
...(visuals?.pluginDir ? { plugins: [{ type: 'local', path: visuals.pluginDir }] } : {}),
extraArgs: {
...configured.extraArgs,
...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs,
...permission.extraArgs,
...thinkingDisplayArgs
launch: {
pathToClaudeCodeExecutable: command,
account,
options: {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
...permission,
...(additionalDirectories.length ? { additionalDirectories } : {}),
...(visuals?.pluginDir ? { plugins: [{ type: 'local', path: visuals.pluginDir }] } : {}),
extraArgs: {
...configured.extraArgs,
...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs,
...permission.extraArgs,
...thinkingDisplayArgs
},
// Claude owns where a resumed conversation continues; the stored leaf is Orca's bookkeeping.
...(resumesTranscript ? { resume: providerSessionId } : { sessionId: providerSessionId })
},
// Claude owns where a resumed conversation continues; the stored leaf is Orca's bookkeeping.
...(resumesTranscript ? { resume: providerSessionId } : { sessionId: providerSessionId })
claudeConfigDir: launchHome,
providerSessionId,
resumeLeafUuid: resumesTranscript ? leafUuid : null,
resumesTranscript,
continuesChain
},
cwd,
env,
claudeConfigDir: launchHome,
providerSessionId,
resumeLeafUuid: resumesTranscript ? leafUuid : null,
resumesTranscript,
continuesChain
// Every structured session speaks orchestration as itself; Claude strips no inherited key.
inheritedEnvToDelete: [],
// Its own runtime's directory goes ahead of this app's CLI directory on PATH.
cliRuntimeCommand: command
}
}
}
@@ -1,3 +1,4 @@
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
@@ -11,7 +12,6 @@ import {
CLAUDE_PLUGIN_DIR_FLAG,
type ClaudeCliFlag
} from './claude-cli-flag-support'
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
const SESSION_ID = 'orca-session-visuals'
const VISUALS: NativeChatVisualsLaunch = {
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity'
import { claudeProcessIdentity } from './claude-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
const IDENTITY = {
@@ -12,7 +13,7 @@ const IDENTITY = {
describe('claude structured owner identity', () => {
it('exports the spawn token env and records the observed process identity', async () => {
expect(CLAUDE_SPAWN_TOKEN_ENV).toBe('ORCA_AGENT_SESSION_SPAWN_TOKEN')
expect(ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV).toBe('ORCA_AGENT_SESSION_SPAWN_TOKEN')
await expect(
claudeProcessIdentity(
{ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 },
@@ -24,10 +24,6 @@ export function claudeProviderHandleLink(input: {
}
}
/** The child echoes its spawn token here so the owner probe can tell a live
* child of this reservation from a same-pid stranger. */
export const CLAUDE_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN'
const START_TIME_READ_ATTEMPTS = 3
export async function claudeProcessIdentity(
@@ -1,3 +1,4 @@
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
// The real Claude CLI, launched through Orca's own launch resolution with a chat's visuals: the
// CLI must report the bundled skill and its plugin in its own init frame. Opt-in, like every
// real-CLI suite: it spends one short turn on the signed-in account.
@@ -13,7 +14,6 @@ import { nativeChatVisualsFolderFor } from '../native-chat/native-chat-visuals-f
import { NATIVE_CHAT_VISUALS_SKILL_NAME } from '../native-chat/native-chat-visuals-skill-location'
import { createClaudeCliFlagSupport } from './claude-cli-flag-support'
import { readClaudeInit } from './claude-structured-init-proof'
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
import {
realClaudeAuthenticated,
realClaudeAvailable,
@@ -14,7 +14,7 @@ import {
readClaudeInit
} from './claude-structured-init-proof'
import { claudeConfigDirEnvPatch } from './claude-config-dir-pin'
import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity'
import { claudeProcessIdentity } from './claude-structured-owner-identity'
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options'
import { observeClaudeFastModeFacts } from './claude-structured-session-options'
@@ -169,7 +169,6 @@ export async function acquireClaudeSession({
cwd: launch.cwd,
env: {
...launch.env,
[CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken,
// Compared against what the child would otherwise inherit, so the record's
// account home still wins over a diverging overlay without a needless pin.
// (`process` is shadowed by a local later in this function, so it is not named here.)
@@ -177,7 +176,8 @@ export async function acquireClaudeSession({
launch.claudeConfigDir,
launch.env ? { env: launch.env } : {}
)
}
},
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {})
},
{
onMessage,
@@ -7,7 +7,7 @@ import {
AgentSessionPromptAnswerRejectedError
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV as SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import {
ClaudeControlRequestError,
ClaudeControlRequestTimeoutError
@@ -44,7 +44,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
expect(claude.connections[0].launch).toMatchObject({
cwd: '/work/repo',
env: {
[CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9',
[SPAWN_TOKEN_ENV]: 'spawn-9',
CLAUDE_CONFIG_DIR: '/accounts/claude'
}
})
@@ -391,7 +391,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
CLAUDE_CONFIG_DIR: '/wrong/account',
[CLAUDE_SPAWN_TOKEN_ENV]: 'wrong-token'
[SPAWN_TOKEN_ENV]: 'wrong-token'
}
})
@@ -401,7 +401,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
CLAUDE_CONFIG_DIR: '/accounts/claude',
[CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9'
[SPAWN_TOKEN_ENV]: 'spawn-9'
})
})
@@ -412,7 +412,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
// Pinning the CLI's own default suppresses the macOS Keychain and breaks claude.ai login.
expect(claude.connections[0].launch.env).toEqual({ [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' })
expect(claude.connections[0].launch.env).toEqual({ [SPAWN_TOKEN_ENV]: 'spawn-9' })
})
it('re-pins the account home when the launch env would send the child elsewhere', async () => {
@@ -427,7 +427,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
expect(claude.connections[0].launch.env).toEqual({
CLAUDE_CONFIG_DIR: accountHome,
[CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9'
[SPAWN_TOKEN_ENV]: 'spawn-9'
})
})
@@ -413,7 +413,8 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => {
identity: {
...identityFor(),
providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf')
}
},
spawnToken: expect.any(String)
})
expect(oldPrompt.settled()).toBe(true)
expect(events.filter((event) => event.type === 'ended')).toEqual([
@@ -100,6 +100,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
atRestCommands?: ClaudeAtRestCommandCatalog
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
spawnToken: string
}) => Promise<ClaudeStructuredLaunch>
onEvent?: (event: ClaudeStructuredSessionEvent) => void
/** Direct settlement path for provider-proven late dispatch outcomes. */
@@ -2,6 +2,7 @@ import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import type {
ClaudeStreamJsonConnection,
ClaudeStreamJsonConnectionHandlers,
@@ -223,10 +224,7 @@ export function fakeClaude(
const replayUuid =
configuredReplayUuid === undefined ? `user-uuid-${replayIndex}` : configuredReplayUuid
if (replayUuid !== null) {
handlers.onMessage?.({
...message,
uuid: replayUuid
})
handlers.onMessage?.({ ...message, uuid: replayUuid })
}
}
},
@@ -269,7 +267,7 @@ export function adapterAtPublishFor(
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate']
): ClaudeStructuredSessionAdapter {
return new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
resolveLaunch: async ({ spawnToken }) => ({
pathToClaudeCodeExecutable: 'claude',
options: {},
cwd: '/work/repo',
@@ -278,7 +276,9 @@ export function adapterAtPublishFor(
resumeLeafUuid: null,
resumesTranscript: false,
continuesChain: false,
...launch
...launch,
// As the composed launch seals it: the reservation's token is the child's, whatever came before.
env: { ...launch.env, [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: spawnToken }
}),
onEvent: (event) => events.push(event),
openConnection: claude.openConnection,
@@ -1,3 +1,4 @@
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
agentSessionLeaseFixture,
@@ -7,7 +8,6 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { reviseAgentSessionProviderResumePoint } from '../runtime/agent-session-provider-handle-transition'
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
import {
ClaudeStructuredSessionAdapter,
type ClaudeStructuredSessionAdapterDeps,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import {
openCodexAppServerConnection,
type CodexAppServerConnection
@@ -57,7 +57,7 @@ async function openServer(
command: process.execPath,
args: ['-e', FORCE_KILL_APP_SERVER],
env: {
[CODEX_SPAWN_TOKEN_ENV]: `teardown-test-${process.pid}-${iteration}`,
[ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: `teardown-test-${process.pid}-${iteration}`,
...(exitMode ? { ORCA_TEST_PROVIDER_EXIT_MODE: exitMode } : {})
}
},
+2 -5
View File
@@ -6,7 +6,7 @@ import { runCodexAppServerSession } from './codex-app-server-session'
import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog'
import {
resolveCodexStructuredInvocation,
type CodexStructuredLaunchResolverDeps
type CodexStructuredInvocationDeps
} from './codex-structured-launch-resolution'
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner'
@@ -15,10 +15,7 @@ import type {
AgentModelCatalogSuccess
} from '../native-chat/agent-model-catalog/agent-model-catalog-store'
export type CodexModelCatalogProbeDeps = Pick<
CodexStructuredLaunchResolverDeps,
'resolveCommand' | 'resolveEnvironment'
> & {
export type CodexModelCatalogProbeDeps = CodexStructuredInvocationDeps & {
resolveAccountKind?: (home: string) => AgentSessionAccountKind | undefined
/** The sync a launch runs on this home first, so the probe reads the login a launch would. */
prepareHome?: (home: string) => void
@@ -1,7 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import { sealStructuredSessionChild } from '../runtime/structured-session-child-env'
import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import {
mintStructuredWorkerHandle,
mintStructuredWorkerPaneKey,
@@ -14,29 +17,53 @@ const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/
// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global.
const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/
describe('buildCodexStructuredChildEnvironment', () => {
it('keeps shell exports while pinned launch values win', () => {
/** The sealed env a Codex launch of `sessionId` spawns with, over a shell env of `env`. */
async function codexChildEnv(
env: Record<string, string>,
sessionId: string
): Promise<Record<string, string>> {
const record = agentSessionRecordFixture({
sessionId,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/pinned/home' }
})
const launch = await createCodexStructuredLaunchResolver({
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveEnvironment: async () => env,
resolveCommand: () => 'codex',
resolveLaunchArgs: () => []
})({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a launch reads only the session id of its identity here.
identity: { sessionId } as AgentSessionJournalIdentity,
spawnToken: 'spawn-token'
})
return launch.env
}
function sealed(sessionId: string): Record<string, string> {
return sealStructuredSessionChild({
sessionId,
spawnToken: 'spawn-token',
env: {},
inheritedEnvToDelete: []
}).env
}
describe('the Codex child environment', () => {
it('keeps shell exports while pinned launch values win', async () => {
expect(
buildCodexStructuredChildEnvironment(
await codexChildEnv(
{
command: 'codex',
args: ['app-server'],
cwd: '/worktree',
codexHome: '/pinned/home',
resumeThreadId: null,
env: {
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/shell/home',
ORCA_CLI_BIN_DIR: '/inherited/unowned-cli'
}
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/shell/home',
ORCA_CLI_BIN_DIR: '/inherited/unowned-cli'
},
'spawn-token',
'session-not-a-worker'
)
).toEqual({
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/pinned/home',
[CODEX_SPAWN_TOKEN_ENV]: 'spawn-token',
[ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: 'spawn-token',
ORCA_AGENT_SESSION_ID: 'session-not-a-worker',
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
@@ -50,17 +77,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
})
it('names every session by its id, and adds the handle only for a registered worker', () => {
const launch = {
command: 'codex',
args: ['app-server'],
cwd: '/worktree',
codexHome: null,
resumeThreadId: null,
env: {}
}
const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
expect(buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)).toEqual({
[CODEX_SPAWN_TOKEN_ENV]: 'spawn-token',
expect(sealed(sessionId)).toEqual({
[ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: 'spawn-token',
// Not a worker, so no handle: the id alone names this chat as a caller.
ORCA_AGENT_SESSION_ID: sessionId,
ORCA_STRUCTURED_SESSION: '1',
@@ -83,7 +102,7 @@ describe('buildCodexStructuredChildEnvironment', () => {
hostScope: { kind: 'local', hostId: 'local' }
})
try {
const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)
const env = sealed(sessionId)
expect(env.ORCA_TERMINAL_HANDLE).toBe(handle)
expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId)
expect(env.ORCA_CLI_COMMAND).toMatch(DEV_CLI_LAUNCHER)
@@ -126,18 +145,7 @@ describe('the spawned Codex child', () => {
// survives the merge — an Orca launched inside another session inherits that session's id.
vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd')
const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
const env = buildCodexStructuredChildEnvironment(
{
command: process.execPath,
args: ['-e', ENV_REPORTING_APP_SERVER],
cwd: process.cwd(),
codexHome: null,
resumeThreadId: null,
env: {}
},
'spawn-token',
sessionId
)
const env = sealed(sessionId)
const connection = await openCodexAppServerConnection({
command: process.execPath,
args: ['-e', ENV_REPORTING_APP_SERVER],
@@ -1,41 +0,0 @@
import type { CodexStructuredLaunch } from './codex-structured-session-state'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env'
import {
NATIVE_CHAT_VISUALS_DIR_ENV,
withNativeChatVisualsEnv
} from '../native-chat/native-chat-visuals-delivery'
export function buildCodexStructuredChildEnvironment(
launch: CodexStructuredLaunch,
spawnToken: string,
sessionId: string
): Record<string, string> {
return {
// Every structured session speaks orchestration as itself: its injected id and the Orca CLI.
...structuredSessionChildIdentityEnv(
sessionId,
withNativeChatVisualsEnv(
{
...launch.env,
...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {})
},
launch.visuals ?? null
)
),
[CODEX_SPAWN_TOKEN_ENV]: spawnToken
}
}
/** The child's env overlay, and the keys removed from what it inherits: without visuals, a folder
* Orca itself inherited (started from a chat) names another chat's folder. */
export function codexStructuredChildEnvironment(
launch: CodexStructuredLaunch,
spawnToken: string,
sessionId: string
): { env: Record<string, string>; envToDelete?: readonly string[] } {
return {
env: buildCodexStructuredChildEnvironment(launch, spawnToken, sessionId),
...(launch.visuals ? {} : { envToDelete: [NATIVE_CHAT_VISUALS_DIR_ENV] })
}
}
@@ -1,3 +1,4 @@
import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -6,9 +7,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution'
import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery'
const { isWindowsProcessStartTimeAvailable } = vi.hoisted(() => ({
isWindowsProcessStartTimeAvailable: vi.fn(() => true)
@@ -165,7 +166,9 @@ describe('codex structured launch resolution', () => {
codexHome: '/home/work/.codex',
resumeThreadId: null,
// Every launch now carries a posture; neither one is left for config.toml to decide.
permissionPolicy: { approvalPolicy: 'on-request', sandbox: 'workspace-write' }
permissionPolicy: { approvalPolicy: 'on-request', sandbox: 'workspace-write' },
env: { CODEX_HOME: '/home/work/.codex' },
envToDelete: [NATIVE_CHAT_VISUALS_DIR_ENV]
})
})
@@ -6,36 +6,35 @@
// must name the thread this session actually proved — never one a caller asks
// for, which is how a resume becomes a fork wearing a resume's name.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { isNativeLocalLocation } from '../../shared/execution-host'
import { resolveCodexCommand } from '../codex-cli/command'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition'
import type { PrepareNativeChatVisuals } from '../native-chat/native-chat-visuals-delivery'
import {
NATIVE_CHAT_VISUALS_DIR_ENV,
withNativeChatVisualsEnv
} from '../native-chat/native-chat-visuals-delivery'
export type CodexStructuredLaunchResolverDeps = {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'pinLaunchDirectory'>
export type CodexStructuredLaunchPartDeps = {
resolveLaunchArgs: () => Promise<string[]> | string[]
/** Absolute path of a workspace on this host. Rejects when the workspace no
* longer resolves, which is the case a stale mobile client hits. */
resolveWorkspacePath: (workspaceId: string) => Promise<string>
/** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
/** Fresh shell/configured environment for this spawn; never written to the session record. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveRollout?: typeof resolvePinnedCodexRolloutProof
/** The user's Agent Permissions setting as thread policy, re-read per acquisition.
* States both postures outright — a resume inherits the last one for any field left absent. */
resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy
/** This chat's visuals folder and skill; absent or null ⇒ the chat gets neither. */
prepareVisuals?: PrepareNativeChatVisuals
}
export type CodexStructuredInvocationDeps = Pick<
CodexStructuredLaunchPartDeps,
'resolveCommand'
> & {
/** Fresh shell/configured environment for this spawn; never written to the session record. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
}
export type CodexStructuredInvocation = {
@@ -51,7 +50,7 @@ export type CodexStructuredInvocation = {
* drift there heals on the next refresh.
*/
export async function resolveCodexStructuredInvocation(
deps: Pick<CodexStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnvironment'>
deps: CodexStructuredInvocationDeps
): Promise<CodexStructuredInvocation> {
const environment = await deps.resolveEnvironment?.()
const pathEnv = environment?.PATH ?? environment?.Path ?? null
@@ -63,63 +62,57 @@ export async function resolveCodexStructuredInvocation(
return { command, environment }
}
export function createCodexStructuredLaunchResolver(
deps: CodexStructuredLaunchResolverDeps
): (input: { identity: AgentSessionJournalIdentity }) => Promise<CodexStructuredLaunch> {
return async ({ identity }) => {
const record = deps.store.getRecord(identity.sessionId)
if (!record) {
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
}
const { location } = record
/** Codex's part of a launch: its binary, thread arguments, account home, resume proof and visuals. */
export function codexStructuredLaunchPart(
deps: CodexStructuredLaunchPartDeps
): StructuredAgentLaunchPartResolver<CodexStructuredLaunch> {
return async (basis) => {
const { record } = basis
const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome)
if (record.provider !== 'codex') {
throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
}
// This adapter spawns a child on the machine the runtime itself runs on.
// A session pinned elsewhere belongs to that host's runtime, and quietly
// starting it here would put a second writer on the same thread.
if (!isNativeLocalLocation(location)) {
throw new Error(
`codex structured sessions run on the local host, not ${location.executionHostId}`
)
}
const pinned = CODEX_STRUCTURED_AGENT.accountHomeVariable
if (accountHome.variable !== pinned) {
throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`)
}
const { command, environment } = await resolveCodexStructuredInvocation(deps)
const { command } = await resolveCodexStructuredInvocation({
...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}),
resolveEnvironment: basis.environment
})
const environment = await basis.environment()
const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs())
const permissionPolicy = deps.resolvePermissionPolicy?.()
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
// A Codex record's chain holds only Codex handles; the attach admission refuses anything else.
// A Codex record's chain holds only Codex handles; the launch admission refuses anything else.
const resumeThreadId = head?.handle.nativeId ?? null
// The same saved options every turn sends, so the thread and its turns name one model.
const model = record.options?.model
const visuals = (await deps.prepareVisuals?.(record.sessionId)) ?? null
const visuals = await basis.visuals()
// Before the rollout scan, so a missing floating folder is still the refusal that wins.
await basis.launchDirectory()
return {
command,
args: [...args, 'app-server'],
cwd: await resolveAgentSessionLaunchDirectory(deps, record),
codexHome: accountHome.path,
...(environment ? { env: { ...environment } as Record<string, string> } : {}),
// An empty chain is a session that has never proved a thread, so it
// starts one; anything else resumes the last link this session proved.
resumeThreadId,
// Only a thread this session created may still be one Codex never saved: a resumed,
// forked or adopted head names a conversation Codex held.
...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}),
...(permissionPolicy ? { permissionPolicy } : {}),
...(model ? { model } : {}),
...(visuals ? { visuals } : {}),
...(resumeThreadId
? {
resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)(
accountHome.path,
resumeThreadId
)
}
: {})
launch: {
command,
args: [...args, 'app-server'],
codexHome: accountHome.path,
// An empty chain is a session that has never proved a thread, so it
// starts one; anything else resumes the last link this session proved.
resumeThreadId,
// Only a thread this session created may still be one Codex never saved: a resumed,
// forked or adopted head names a conversation Codex held.
...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}),
...(permissionPolicy ? { permissionPolicy } : {}),
...(model ? { model } : {}),
...(visuals ? { visuals } : {}),
...(resumeThreadId
? {
resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)(
accountHome.path,
resumeThreadId
)
}
: {})
},
env: withNativeChatVisualsEnv(
{ ...environment, ...(accountHome.path ? { CODEX_HOME: accountHome.path } : {}) },
visuals
),
// Without visuals, a folder Orca itself inherited (started from a chat) names another chat's.
inheritedEnvToDelete: visuals ? [] : [NATIVE_CHAT_VISUALS_DIR_ENV]
}
}
}
@@ -12,10 +12,6 @@ import { readProcessStartTimeMs } from '../runtime/agent-session-process-identit
// must describe the thread Codex actually opened, never the one a client asked
// for.
/** The child echoes its spawn token here so the owner probe can tell a live
* child of THIS reservation from a same-pid stranger. */
export const CODEX_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN'
const START_TIME_READ_ATTEMPTS = 3
/**
@@ -22,7 +22,6 @@ import {
codexProviderHandleLink,
codexSpawnedProcessIdentity
} from './codex-structured-owner-identity'
import { codexStructuredChildEnvironment } from './codex-structured-child-environment'
import { openCodexThread } from './codex-structured-thread-open'
import { withCodexVisualsThreadConfig } from './codex-structured-visuals'
import {
@@ -115,7 +114,7 @@ export async function acquireCodexStructuredSession(input: {
}
acquisitions.assertCurrent(sessionId, attempt)
const launch = await deps
.resolveLaunch({ identity: acquireInput.identity })
.resolveLaunch({ identity: acquireInput.identity, spawnToken: acquireInput.spawnToken })
.catch((error: unknown) => {
throw new AgentSessionPreSpawnError(error)
})
@@ -126,7 +125,8 @@ export async function acquireCodexStructuredSession(input: {
command: launch.command,
args: launch.args,
cwd: launch.cwd,
...codexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId)
...(launch.env ? { env: launch.env } : {}),
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {})
},
{
onNotification: codexAcquisitionNotificationHandler({
@@ -4,7 +4,7 @@ import {
type openCodexAppServerConnection
} from './codex-app-server-connection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import {
CodexStructuredSessionAdapter,
type CodexStructuredLaunch,
@@ -20,6 +20,8 @@ import {
identityFor
} from './codex-structured-session-adapter-fixture'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
describe('CodexStructuredSessionAdapter.acquire', () => {
@@ -124,7 +126,20 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
it('starts a new thread and reports the process and link the lease will prove', async () => {
const codex = fakeCodex()
const adapter = adapterFor(codex, { codexHome: '/codex/home' })
const record = agentSessionRecordFixture({
sessionId: 'session-1',
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/codex/home' }
})
// The launch its registration composes: the shared seal names the child, not this adapter.
const adapter = adapterFor(codex, {}, [], {
resolveLaunch: createCodexStructuredLaunchResolver({
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async () => '/work/repo',
resolveCommand: () => 'codex',
resolveLaunchArgs: () => []
})
})
const acquisition = await adapter.acquire({
identity: identityFor('session-1'),
@@ -133,7 +148,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
})
expect(codex.connections[0].launch.env).toEqual({
[CODEX_SPAWN_TOKEN_ENV]: 'spawn-9',
[ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: 'spawn-9',
CODEX_HOME: '/codex/home',
ORCA_AGENT_SESSION_ID: 'session-1',
ORCA_STRUCTURED_SESSION: '1',
@@ -44,7 +44,9 @@ export type CodexStructuredLaunch = {
permissionPolicy?: CodexStructuredPermissionPolicy
/** The model the session chose; the thread opens on it so its first turn is not a switch. */
model?: string
/** The child's sealed environment, and the inherited keys it must not keep. */
env?: Record<string, string>
envToDelete?: readonly string[]
/** This chat's visuals folder and skill; absent when the chat has no visuals. */
visuals?: NativeChatVisualsLaunch
}
@@ -90,6 +92,7 @@ export type CodexStructuredSessionAdapterDeps = {
resolveAccountKind?: (home: string) => AgentSessionAccountKind | undefined
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
spawnToken: string
}) => Promise<CodexStructuredLaunch>
onEvent?: (event: CodexStructuredSessionEvent) => void
/** Where bookkeeping a close or exit does after the child is gone reports a failure. */
@@ -5,7 +5,15 @@ import {
} from '../native-chat/native-chat-visuals-delivery'
import { CodexAppServerRequestError } from './codex-app-server-request-error'
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import { acquired, fakeCodex, THREAD_ID } from './codex-structured-session-adapter-fixture'
import {
adapterFor,
fakeCodex,
identityFor,
THREAD_ID
} from './codex-structured-session-adapter-fixture'
import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import {
CODEX_VISUALS_SETUP_BUDGET_MS,
prepareCodexThreadForVisuals
@@ -147,11 +155,51 @@ describe('setting up a Codex app-server for a chat with visuals', () => {
})
})
/** A chat acquired through the launch its registration composes, over a shell env of `env`. */
async function acquired(
codex: ReturnType<typeof fakeCodex>,
launch: {
visuals?: NativeChatVisualsLaunch
resumeThreadId?: string | null
env?: Record<string, string>
}
): Promise<void> {
const record = agentSessionRecordFixture({
sessionId: 'session-1',
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' },
providerHandleChain: launch.resumeThreadId
? [
{
linkId: 'link-1',
handle: codexProviderHandle(launch.resumeThreadId),
origin: 'resumed',
mintedAtFence: 1,
observedAt: 1
}
]
: []
})
const adapter = adapterFor(codex, {}, [], {
resolveLaunch: createCodexStructuredLaunchResolver({
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async () => '/work/repo',
resolveEnvironment: async () => launch.env ?? {},
resolveCommand: () => 'codex',
resolveLaunchArgs: () => [],
resolveRollout: async () => null,
resolvePermissionPolicy: () => MANUAL,
prepareVisuals: async () => launch.visuals ?? null
})
})
await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' })
}
describe('a Codex chat acquisition with visuals', () => {
it('sets up skills and the folder after initialize and before the thread opens, on start and resume', async () => {
for (const resumeThreadId of [null, THREAD_ID]) {
const codex = fakeCodex({ 'config/read': configWithRoots(['/home/me/scratch']) })
await acquired(codex, { visuals: VISUALS, permissionPolicy: MANUAL, resumeThreadId })
await acquired(codex, { visuals: VISUALS, resumeThreadId })
const connection = codex.connections[0]!
const methods = connection.calls.map((call) => call.method)
const open = resumeThreadId ? 'thread/resume' : 'thread/start'
@@ -167,10 +215,7 @@ describe('a Codex chat acquisition with visuals', () => {
it('opens the thread exactly as before for a chat without visuals', async () => {
const codex = fakeCodex()
await acquired(codex, {
permissionPolicy: MANUAL,
env: { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/other' }
})
await acquired(codex, { env: { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/other' } })
const connection = codex.connections[0]!
const methods = connection.calls.map((call) => call.method)
expect(methods[0]).toBe('thread/start')
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { spawnProcess } from '@orca/process-host'
import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../../shared/agent-session-caller-env'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe'
@@ -39,7 +39,7 @@ async function spawnOwner(spawnToken: string) {
const child = spawnProcess({
program: process.execPath,
args: ['-e', 'setInterval(() => {}, 1_000)'],
env: { ...process.env, [CODEX_SPAWN_TOKEN_ENV]: spawnToken }
env: { ...process.env, [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: spawnToken }
})
spawnedOwners.add(child)
const pid = child.pid
@@ -1,11 +1,13 @@
import {
createClaudeStructuredLaunchResolver,
createCodexStructuredLaunchResolver
} from '../runtime/structured-agent-launch-composition.test-support'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution'
import { CLAUDE_PLUGIN_DIR_FLAG, type ClaudeCliFlag } from '../claude/claude-cli-flag-support'
import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution'
import {
adapterFor,
fakeCodex,
@@ -16,7 +16,9 @@ import {
} from './native-chat-visuals-delivery'
import { nativeChatVisualsFolderFor, nativeChatVisualsRootFor } from './native-chat-visuals-folder'
import { buildClaudeChildProcessEnv } from '../claude/claude-child-process-environment'
import { codexStructuredChildEnvironment } from '../codex/codex-structured-child-environment'
import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { agentSessionRecordFixture } from './agent-session-record-test-fixture'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { resolveProviderChildEnv } from '../provider-process/provider-process-launch'
import {
NATIVE_CHAT_VISUALS_SKILL_NAME,
@@ -116,7 +118,7 @@ describe('preparing a chat for visuals', () => {
)
})
it("never hands a chat another chat's folder that Orca itself inherited", () => {
it("never hands a chat another chat's folder that Orca itself inherited", async () => {
const inherited = { PATH: '/bin', [NATIVE_CHAT_VISUALS_DIR_ENV]: '/parent-chat' }
const visuals = { folder: '/mine', skill: SKILL }
for (const platform of ['darwin', 'win32'] as const) {
@@ -127,24 +129,27 @@ describe('preparing a chat for visuals', () => {
'/mine'
)
}
const codex = (withVisuals: boolean) =>
const record = agentSessionRecordFixture({
sessionId: 'chat-1',
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' }
})
const codex = async (withVisuals: boolean) =>
resolveProviderChildEnv(
codexStructuredChildEnvironment(
{
command: 'codex',
args: ['app-server'],
cwd: '/w',
codexHome: null,
resumeThreadId: null,
...(withVisuals ? { visuals } : {})
},
'spawn-token',
'chat-1'
),
await createCodexStructuredLaunchResolver({
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveCommand: () => 'codex',
resolveLaunchArgs: () => [],
prepareVisuals: async () => (withVisuals ? visuals : null)
})({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a launch reads only the session id of its identity here.
identity: { sessionId: 'chat-1' } as AgentSessionJournalIdentity,
spawnToken: 'spawn-token'
}),
inherited
)
expect(codex(false)).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV)
expect(codex(true)[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe('/mine')
expect(await codex(false)).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV)
expect((await codex(true))[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe('/mine')
})
it('names only this chat folder to the agent', () => {
+3 -2
View File
@@ -1,3 +1,4 @@
import { createPiRpcLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -9,7 +10,7 @@ import { agentSessionProviderHandleKey } from '../../shared/agent-session-provid
import { closeTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness'
import { buildPiRpcLaunch } from './rpc-launch'
import { createPiRpcLaunchResolver, piRpcProviderLink } from './rpc-launch-resolution'
import { piRpcProviderLink } from './rpc-launch-resolution'
const probeVersion = vi.fn(async (..._input: unknown[]) => true)
@@ -237,7 +238,7 @@ describe('Pi host launch resolution', () => {
...record,
location: { ...record.location, executionHostId: 'ssh:remote' }
})
await expect(h.resolver(identity)).rejects.toThrow('another execution host')
await expect(h.resolver(identity)).rejects.toThrow('run on the local host')
vi.spyOn(h.store, 'getRecord').mockReturnValue({
...record,
accountHome: { variable: 'CODEX_HOME', path: '/wrong' }
+30 -50
View File
@@ -7,30 +7,26 @@ import {
agentSessionProviderHandleKey,
type AgentSessionProviderHandleLink
} from '../../shared/agent-session-provider-handle'
import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import type { resolveCliCommand } from '../../shared/node-cli-command-resolution'
import { isStableCliVersionOnLine, probeAgentCliVersion } from '../agent-cli-version-probe'
import {
resolveStructuredAgentCommand,
type StructuredAgentCommandSettings
} from '../native-chat/structured-agent-command-resolution'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
import type { PiRpcLaunchOptions } from './rpc-launch'
import {
PI_RPC_SESSION_ENV_TO_DELETE,
withoutPiCallerEnv,
type PiRpcLaunchOptions
} from './rpc-launch'
export type PiRpcResolvedLaunch = PiRpcLaunchOptions & {
previous: AgentSessionProviderHandleLink | null
replacement?: 'unsaved' | 'restore-failed'
}
export type PiRpcLaunchResolverDeps = {
store: AgentSessionRecordStore
resolveWorkspacePath: (id: string) => Promise<string>
resolveEnvironment: () => Promise<NodeJS.ProcessEnv>
/** The user's Pi Command setting, re-read per acquisition; none runs the stock binary. */
resolveCommandSettings?: () => StructuredAgentCommandSettings
export type PiRpcLaunchPartDeps = {
resolveCommand?: typeof resolveCliCommand
probeVersion?: typeof probeAgentCliVersion
resolveFullAccess?: () => boolean
@@ -102,39 +98,21 @@ async function sameDirectory(left: string, right: string): Promise<boolean> {
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
}
/** Reads the execution host's durable record; never resolves another host's files or account. */
export function createPiRpcLaunchResolver(
deps: PiRpcLaunchResolverDeps
): (identity: AgentSessionJournalIdentity) => Promise<PiRpcResolvedLaunch> {
return async (identity) => {
const record = deps.store.getRecord(identity.sessionId)
if (!record || record.provider !== 'pi') {
throw new Error('Pi session record unavailable')
}
if (
record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
record.location.wslDistro !== null
) {
throw new Error('Pi structured session belongs to another execution host')
}
/** Pi's part of a launch: its account directory, session file or fork, binary and release check. */
export function piRpcLaunchPart(
deps: PiRpcLaunchPartDeps
): StructuredAgentLaunchPartResolver<PiRpcResolvedLaunch> {
return async (basis) => {
const { record } = basis
if (process.platform === 'win32' && !isWindowsProcessStartTimeAvailable()) {
throw new Error('Pi structured sessions require Windows process creation-time proof')
}
const accountHome = record.accountHome
if (
!isLegacyAgentSessionAccountHome(accountHome) ||
accountHome.variable !== 'PI_CODING_AGENT_DIR'
) {
throw new Error('Pi account home does not match its binary')
const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome)
const cwd = await basis.launchDirectory()
const env: Record<string, string> = {
...(await basis.environment()),
PI_CODING_AGENT_DIR: accountHome.path
}
const cwd = await resolveAgentSessionLaunchDirectory(deps, record)
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(await deps.resolveEnvironment())) {
if (value !== undefined) {
env[key] = value
}
}
env.PI_CODING_AGENT_DIR = accountHome.path
const previous = agentSessionProviderHandleChainHead(record.providerHandleChain)
let sessionFile: string | undefined
let forkFile: string | undefined
@@ -157,7 +135,7 @@ export function createPiRpcLaunchResolver(
forkFile = source
}
}
const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand)
const command = resolvePiRpcCommand(env, basis.commandSettings(), deps.resolveCommand)
// Again at every launch: the binary on PATH may have changed since the chat was created.
if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) {
throw agentSessionRefusalError('structured_agent_session_unsupported', {
@@ -165,14 +143,16 @@ export function createPiRpcLaunchResolver(
})
}
return {
command,
cwd,
env,
fullAccess: deps.resolveFullAccess?.() ?? true,
previous,
...(sessionFile ? { sessionFile } : {}),
...(forkFile ? { forkFile } : {}),
...(replacement ? { replacement } : {})
launch: {
command,
fullAccess: deps.resolveFullAccess?.() ?? true,
previous,
...(sessionFile ? { sessionFile } : {}),
...(forkFile ? { forkFile } : {}),
...(replacement ? { replacement } : {})
},
env: withoutPiCallerEnv(env),
inheritedEnvToDelete: PI_RPC_SESSION_ENV_TO_DELETE
}
}
}
+22 -6
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { resolveProviderChildEnv } from '../provider-process/provider-process-launch'
import { buildPiRpcLaunch } from './rpc-launch'
import { sealStructuredSessionChild } from '../runtime/structured-session-child-env'
import {
buildPiRpcLaunch,
PI_RPC_SESSION_ENV_TO_DELETE,
piRpcSessionlessEnvironment,
withoutPiCallerEnv
} from './rpc-launch'
const options = { command: 'host-resolved-pi', cwd: '/host/folder', fullAccess: true }
describe('Pi RPC launch', () => {
@@ -28,11 +34,11 @@ describe('Pi RPC launch', () => {
const launch = buildPiRpcLaunch({
...options,
extraArgs: ['--extension', '/host/my extension.ts'],
env: {
...piRpcSessionlessEnvironment({
PI_CODING_AGENT_DIR: '/host/account',
ORCA_PANE_KEY: 'other-pane',
ORCA_AGENT_PANE: 'alias'
}
})
})
const env = resolveProviderChildEnv(launch, {
ORCA_AGENT_HOOK_TOKEN: 'inherited',
@@ -91,12 +97,22 @@ describe('Pi RPC launch', () => {
ORCA_TERMINAL_HANDLE: 'structworker_stale',
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'old-token'
}
expect(resolveProviderChildEnv(buildPiRpcLaunch(options), inherited)).toEqual({})
expect(
resolveProviderChildEnv(
buildPiRpcLaunch({ ...options, ...piRpcSessionlessEnvironment({}) }),
inherited
)
).toEqual({})
// As a structured launch seals it: no caller of its own env, then this session's identity.
const childEnv = resolveProviderChildEnv(
buildPiRpcLaunch({
...options,
env: inherited,
structuredSession: { id: 'this-session', spawnToken: 'this-token' }
...sealStructuredSessionChild({
sessionId: 'this-session',
spawnToken: 'this-token',
env: withoutPiCallerEnv(inherited),
inheritedEnvToDelete: PI_RPC_SESSION_ENV_TO_DELETE
})
}),
inherited
)
+33 -39
View File
@@ -1,13 +1,7 @@
import {
ORCA_SCRUB_SAFE_LAUNCH_ENV,
ORCA_SCRUB_SAFE_PANE_ENV
} from '../../shared/agent-hook-scrub-safe-env'
import {
AGENT_HOOK_RUNTIME_ENV_KEYS,
ORCA_AGENT_SESSION_CALLER_ENV_KEYS
} from '../ipc/pty/host-env/spawn-env-keys'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import { ORCA_AGENT_SESSION_CALLER_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
export type PiRpcLaunchOptions = {
/** Binary and paths are resolved by the execution host before building the launch. */
@@ -16,26 +10,43 @@ export type PiRpcLaunchOptions = {
fullAccess: boolean
extraArgs?: readonly string[]
env?: Record<string, string>
envToDelete?: readonly string[]
sessionFile?: string
forkFile?: string
structuredSession?: { id: string; spawnToken: string }
}
const CHILD_ENV_TO_DELETE: readonly string[] = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN',
ORCA_SCRUB_SAFE_PANE_ENV,
ORCA_SCRUB_SAFE_LAUNCH_ENV,
...AGENT_HOOK_RUNTIME_ENV_KEYS
]
const CALLER_ENV_TO_DELETE = [
...ORCA_AGENT_SESSION_CALLER_ENV_KEYS,
'ORCA_TERMINAL_HANDLE',
'ORCA_AGENT_SESSION_SPAWN_TOKEN'
ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV
]
/** What a structured Pi session strips from its inheritance; the seal keeps a worker's handle. */
export const PI_RPC_SESSION_ENV_TO_DELETE: readonly string[] = [
...STRUCTURED_CHILD_ENV_TO_DELETE,
'ORCA_TERMINAL_HANDLE'
]
/** `env` naming no caller: a structured session's seal names its own. */
export function withoutPiCallerEnv(env: Readonly<Record<string, string>>): Record<string, string> {
const next = { ...env }
for (const key of CALLER_ENV_TO_DELETE) {
delete next[key]
}
return next
}
/** A Pi child that is no structured session (a catalog listing) names no caller at all. */
export function piRpcSessionlessEnvironment(env: Readonly<Record<string, string>>): {
env: Record<string, string>
envToDelete: readonly string[]
} {
return {
env: withoutPiCallerEnv(env),
envToDelete: [...STRUCTURED_CHILD_ENV_TO_DELETE, ...CALLER_ENV_TO_DELETE]
}
}
/** Pi stores its own sessions; an explicit session file is shared with terminal resumes. */
export function buildPiRpcLaunch(options: PiRpcLaunchOptions): ProviderProcessLaunch {
if (!options.fullAccess) {
@@ -50,16 +61,6 @@ export function buildPiRpcLaunch(options: PiRpcLaunchOptions): ProviderProcessLa
) {
throw new Error('Pi fork requires one source session file')
}
const env = { ...options.env }
for (const key of CALLER_ENV_TO_DELETE) {
delete env[key]
}
const childEnv = options.structuredSession
? {
...structuredSessionChildIdentityEnv(options.structuredSession.id, env),
ORCA_AGENT_SESSION_SPAWN_TOKEN: options.structuredSession.spawnToken
}
: env
const args = [...(options.extraArgs ?? [])]
let provider = false
let model = false
@@ -106,14 +107,7 @@ export function buildPiRpcLaunch(options: PiRpcLaunchOptions): ProviderProcessLa
...(options.sessionFile ? ['--session', options.sessionFile] : []),
...(options.forkFile ? ['--fork', options.forkFile] : [])
],
env: childEnv,
envToDelete: [
...CHILD_ENV_TO_DELETE,
...(options.structuredSession
? Object.hasOwn(childEnv, 'ORCA_TERMINAL_HANDLE')
? []
: ['ORCA_TERMINAL_HANDLE']
: CALLER_ENV_TO_DELETE)
]
env: { ...options.env },
envToDelete: [...(options.envToDelete ?? [])]
}
}
+7 -2
View File
@@ -9,7 +9,7 @@ import type {
} from '../native-chat/agent-model-catalog/agent-model-catalog-store'
import { runAgentModelCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner'
import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution'
import { buildPiRpcLaunch } from './rpc-launch'
import { buildPiRpcLaunch, piRpcSessionlessEnvironment } from './rpc-launch'
import { piRpcVersionSupported, resolvePiRpcCommand } from './rpc-launch-resolution'
import { PI_UNMAPPED_THINKING_LEVELS } from './rpc-options'
@@ -52,7 +52,12 @@ export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentM
throw new Error(`pi at ${command} does not run structured chats`)
}
// The chat launch's own env rules; only the arguments differ.
const launch = buildPiRpcLaunch({ command, cwd, env, fullAccess: true })
const launch = buildPiRpcLaunch({
command,
cwd,
...piRpcSessionlessEnvironment(env),
fullAccess: true
})
const stdout = await (deps.runListing ?? runAgentModelCatalogListing)(
{ ...launch, args: ['--list-models'] },
{ site: 'pi-model-catalog-probe', signal: options?.signal }
+12 -22
View File
@@ -5,41 +5,30 @@ import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/s
import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location'
import type {
StructuredAgentAdapterContext,
StructuredAgentModelCatalogContext,
StructuredAgentRuntimeAdapter,
StructuredAgentRuntimeRegistration
} from '../runtime/structured-agent-runtime-registrations'
import {
composeStructuredLaunch,
structuredAgentLaunchSources
} from '../runtime/structured-agent-launch-composition'
import { PI_RPC_AGENT } from './rpc-agent-definition'
import {
createPiRpcLaunchResolver,
piRpcLaunchPart,
piRpcVersionSupported,
resolvePiRpcCommand
} from './rpc-launch-resolution'
import { PiRpcSessionAdapter } from './rpc-session-adapter'
import { createPiModelCatalogProbe } from './rpc-model-catalog-probe'
/** The environment every Pi child starts from: a chat's launch and a catalog listing alike. */
function piEnvironment({
deps,
environment
}: StructuredAgentModelCatalogContext): () => Promise<NodeJS.ProcessEnv> {
return async () => ({
...(await environment.resolveBaseEnvironment()),
...deps.resolveAgentLaunchEnv?.('pi')
})
}
function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter {
const { deps } = context
return new PiRpcSessionAdapter({
resolveLaunch: createPiRpcLaunchResolver({
store: context.store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveEnvironment: piEnvironment(context),
...(deps.resolveAgentCommandSettings
? { resolveCommandSettings: deps.resolveAgentCommandSettings }
: {})
}),
resolveLaunch: composeStructuredLaunch(
PI_RPC_AGENT,
structuredAgentLaunchSources(context),
piRpcLaunchPart({})
),
...(deps.openPiConnection ? { openConnection: deps.openPiConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
onLifecycle: context.deliverLifecycle,
@@ -66,7 +55,8 @@ export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = {
// `--list-models` marks no model as the one Pi runs by default.
listingNamesConfiguredModel: false,
probe: createPiModelCatalogProbe({
resolveEnvironment: piEnvironment(context),
// The environment a chat's launch starts from too.
resolveEnvironment: () => context.environment.resolveAgentEnvironment('pi'),
...(context.deps.resolveAgentCommandSettings
? { resolveCommandSettings: context.deps.resolveAgentCommandSettings }
: {})
+5 -7
View File
@@ -24,9 +24,10 @@ import { supportsSupervisedProviderChildLocation } from '../provider-process/sup
import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content'
export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & {
resolveLaunch: (
resolveLaunch: (input: {
identity: StructuredAgentSessionAcquireInput['identity']
) => Promise<PiRpcResolvedLaunch>
spawnToken: string
}) => Promise<PiRpcResolvedLaunch>
readProcessStartTime?: (pid: number) => Promise<number | null>
}
@@ -69,7 +70,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter {
let launch: PiRpcResolvedLaunch
try {
launch = await waitForPromiseWithSignal(
this.deps.resolveLaunch(input.identity),
this.deps.resolveLaunch({ identity: input.identity, spawnToken: input.spawnToken }),
attempt.signal
)
} catch (error) {
@@ -78,10 +79,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter {
if (attempt.signal.aborted) {
throw new AgentSessionPreSpawnError(new Error('Pi closed while starting'))
}
const spec = buildPiRpcLaunch({
...launch,
structuredSession: { id, spawnToken: input.spawnToken }
})
const spec = buildPiRpcLaunch(launch)
const fresh = !launch.sessionFile && !launch.forkFile
session = new PiRpcSession(input, randomUUID(), spec, this.deps, fresh)
this.sessions.set(id, session)
@@ -4,10 +4,6 @@ import { readProcessStartTimeMs } from '../runtime/agent-session-process-identit
// What the lease records about a provider child: the process a later owner probe re-proves.
/** The child echoes its spawn token here so the owner probe can tell a live child of THIS
* reservation from a same-pid stranger. */
export const PROVIDER_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN'
const START_TIME_READ_ATTEMPTS = 3
type SpawnedIdentityInput = {
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@
*/
import { readFile, readdir } from 'node:fs/promises'
import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import { spawnTokenFromEnvironBlock } from './agent-session-spawn-token-readback'
export type AgentSessionSpawnTokenScan = ReadonlyMap<string, readonly number[]>
@@ -21,7 +21,7 @@ export type AgentSessionSpawnTokenScanEvidence =
/** Tokens observed on this host, or null when the platform cannot answer at all. */
export async function scanAgentSessionSpawnTokenProcesses(
platform: NodeJS.Platform = process.platform,
variable: string = CODEX_SPAWN_TOKEN_ENV
variable: string = ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV
): Promise<AgentSessionSpawnTokenScan | null> {
if (platform !== 'linux') {
return null
@@ -7,11 +7,11 @@
import { readFile } from 'node:fs/promises'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
export function spawnTokenFromEnvironBlock(
block: string,
variable: string = CODEX_SPAWN_TOKEN_ENV
variable: string = ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV
): string | null {
for (const entry of block.split('\0')) {
if (entry.startsWith(`${variable}=`)) {
@@ -12,7 +12,7 @@ import {
} from '../../shared/protocol-version'
import { fakeClaude } from './claude-structured-fake-connection-test-fixture'
import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution'
import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
@@ -245,7 +245,7 @@ beforeEach(async () => {
resolveCodexCommand: () => '/usr/local/bin/codex',
resolveClaudeCommand: () => '/usr/local/bin/claude',
readProcessStartTime: async (pid: number) => pid * 10,
resolveClaudeLaunchEnv: () => claudeLaunchEnv,
resolveAgentLaunchEnv: (agent) => (agent === 'claude' ? claudeLaunchEnv : {}),
// Hermetic: never the developer's real login shell.
resolveEnvironment: async () => shellEnv,
resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy,
@@ -582,7 +582,7 @@ describe('a structured Claude session over agentSession.*', () => {
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
CLAUDE_CONFIG_DIR: join(root, 'claude-home'),
[CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String)
[ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: expect.any(String)
})
// System auth: the user's own shell key is their sign-in, exactly as on the
// terminal path, and the configured overlay still wins over it.
@@ -210,10 +210,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis
resolveStructuredAgentCommand('codex', this.requireStore().getSettings(), options),
resolveLaunchArgs: (agent) =>
structuredAgentConfiguredArgs(agent, this.requireStore().getSettings()),
resolveLaunchEnvOverlay: () =>
resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv),
resolveClaudeLaunchEnv: () =>
resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv),
// Wired only here, so a test runtime never runs a real `claude --version`.
claudeCliFlags: claudeCliFlagSupport,
nativeChatVisuals: {
@@ -22,8 +22,7 @@ const {
} = await import('./orchestration-structured-worker-session')
const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/worker-topology')
const { structuredWorkerIdentities } = await import('../../structured-worker-identity')
const { structuredSessionChildIdentityEnv } =
await import('../../structured-session-child-identity-env')
const { sealedChildEnv } = await import('../../structured-session-child-env.test-support')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a host stub carrying only the members the worker start reaches.
function installHost(location = { executionHostId: 'local', wslDistro: null as string | null }) {
@@ -85,7 +84,7 @@ describe('structured worker session', () => {
createSpy.mockImplementation(async (args: { envelope: { sessionId: string } }) => {
// `attach` is what spawns the provider child, and the child's env is read from the registry
// at spawn time. Registering afterwards ships a worker with no ORCA_TERMINAL_HANDLE.
envAtSpawn = structuredSessionChildIdentityEnv(args.envelope.sessionId, {})
envAtSpawn = sealedChildEnv(args.envelope.sessionId)
return { ok: true, value: { sessionId: args.envelope.sessionId } }
})
const created = await createStructuredWorkerSession({
@@ -0,0 +1,137 @@
// Each agent's composed launch resolver over test doubles, built the way its registration builds
// it, so resolver tests exercise the shared launch path rather than a copy of it.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AcpLaunchSpec } from '../acp/acp-launch-specs'
import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions'
import {
acpStructuredLaunchPart,
type AcpStructuredLaunchPartDeps
} from '../acp/acp-structured-launch-resolution'
import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition'
import type { ClaudeEnvDeps } from '../claude/claude-structured-child-env'
import {
claudeStructuredLaunchPart,
type ClaudeStructuredLaunchPartDeps
} from '../claude/claude-structured-launch-resolution'
import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition'
import {
codexStructuredLaunchPart,
type CodexStructuredLaunchPartDeps
} from '../codex/codex-structured-launch-resolution'
import type { PrepareNativeChatVisuals } from '../native-chat/native-chat-visuals-delivery'
import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution'
import { PI_RPC_AGENT } from '../pi/rpc-agent-definition'
import { piRpcLaunchPart, type PiRpcLaunchPartDeps } from '../pi/rpc-launch-resolution'
import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition'
import {
composeStructuredLaunch,
composeStructuredLaunchRead,
type StructuredAgentLaunchPartResolver,
type StructuredAgentLaunchSources
} from './structured-agent-launch-composition'
export const TEST_SPAWN_TOKEN = 'test-spawn-token'
export type LaunchTestSources = Pick<StructuredAgentLaunchSources, 'store'> & {
resolveWorkspacePath?: (workspaceId: string) => Promise<string>
/** The base env; none is an empty one. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveLaunchEnv?: (agent: string) => Record<string, string>
resolveCommandSettings?: () => StructuredAgentCommandSettings
prepareVisuals?: PrepareNativeChatVisuals
}
function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
)
}
function launchSources(deps: LaunchTestSources): StructuredAgentLaunchSources {
const resolveEnvironment = deps.resolveEnvironment
const resolveBaseEnvironment = async () => definedEnv((await resolveEnvironment?.()) ?? {})
return {
store: deps.store,
resolveWorkspacePath: deps.resolveWorkspacePath ?? (async (id) => `/repos/${id}`),
resolveBaseEnvironment,
resolveAgentEnvironment: async (agent) => ({
...(await resolveBaseEnvironment()),
...deps.resolveLaunchEnv?.(agent)
}),
...(deps.resolveLaunchEnv ? { resolveAgentLaunchEnv: deps.resolveLaunchEnv } : {}),
...(deps.resolveCommandSettings ? { resolveCommandSettings: deps.resolveCommandSettings } : {}),
...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {})
}
}
/** Sealed with a spawn token, as an adapter's start calls it; with none, the unsealed read. */
function withTestSpawnToken<L>(
definition: StructuredAgentDefinition,
sources: StructuredAgentLaunchSources,
part: StructuredAgentLaunchPartResolver<L>,
defaultToken: string | null
) {
const resolve = composeStructuredLaunch(definition, sources, part)
const read = composeStructuredLaunchRead(definition, sources, part)
return (input: { identity: AgentSessionJournalIdentity; spawnToken?: string | null }) => {
const spawnToken = input.spawnToken === undefined ? defaultToken : input.spawnToken
return spawnToken === null
? read(input.identity)
: resolve({ identity: input.identity, spawnToken })
}
}
/** Unsealed unless a token is passed: the env an agent part produced, before the seal. */
export function createCodexStructuredLaunchResolver(
deps: LaunchTestSources & CodexStructuredLaunchPartDeps
) {
return withTestSpawnToken(
CODEX_STRUCTURED_AGENT,
launchSources(deps),
codexStructuredLaunchPart(deps),
null
)
}
/** Unsealed unless a token is passed: the env an agent part produced, before the seal. */
export function createAcpStructuredLaunchResolver(
spec: AcpLaunchSpec,
deps: LaunchTestSources & AcpStructuredLaunchPartDeps
) {
return withTestSpawnToken(
acpStructuredAgentDefinition(spec),
launchSources(deps),
acpStructuredLaunchPart(spec, deps),
null
)
}
/** Unsealed unless a token is passed: the env an agent part produced, before the seal. */
export function createPiRpcLaunchResolver(deps: LaunchTestSources & PiRpcLaunchPartDeps) {
const resolve = withTestSpawnToken(PI_RPC_AGENT, launchSources(deps), piRpcLaunchPart(deps), null)
return (identity: AgentSessionJournalIdentity, spawnToken: string | null = null) =>
resolve({ identity, spawnToken })
}
/** Sealed with `TEST_SPAWN_TOKEN` unless the test passes another: Claude's launch env has always
* carried the session's identity. `resolveEnv` is Claude's overlay; `resolveInheritedEnv`, its
* base, defaults to this process's env as a launch without one inherits it. */
export function createClaudeStructuredLaunchResolver(
deps: Omit<LaunchTestSources, 'resolveEnvironment' | 'resolveLaunchEnv'> &
Pick<ClaudeEnvDeps, 'resolveInheritedEnv'> & {
resolveEnv?: () => Record<string, string> | undefined
} & ClaudeStructuredLaunchPartDeps
) {
const { resolveEnv, resolveInheritedEnv } = deps
return withTestSpawnToken(
CLAUDE_STRUCTURED_AGENT,
launchSources({
...deps,
resolveEnvironment: resolveInheritedEnv ?? (async () => process.env),
...(resolveEnv ? { resolveLaunchEnv: () => resolveEnv() ?? {} } : {})
}),
claudeStructuredLaunchPart(deps),
TEST_SPAWN_TOKEN
)
}
@@ -0,0 +1,210 @@
// The one launch path every native-chat agent's resolver runs through. The shared steps live here:
// admitting the record, its launch directory, the base environment and the user's overlay, the
// chat's visuals, and sealing the child. An agent's own part never sets the directory or the
// sealed environment; it only adds what is that agent's.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { isNativeLocalLocation } from '../../shared/execution-host'
import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition'
import { agentDrivesSession } from '../native-chat/agent-session-wire/structured-agent-session-provider-support'
import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution'
import {
createNativeChatVisualsDelivery,
type NativeChatVisualsLaunch,
type PrepareNativeChatVisuals
} from '../native-chat/native-chat-visuals-delivery'
import { resolveAgentSessionLaunchDirectory } from './agent-session-launch-directory'
import type { AgentSessionRecordStore } from './agent-session-record-store'
import type { StructuredAgentAdapterContext } from './structured-agent-runtime-registrations'
import { sealStructuredSessionChild } from './structured-session-child-env'
/** Where a launch's shared inputs come from. */
export type StructuredAgentLaunchSources = {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'pinLaunchDirectory'>
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveBaseEnvironment: () => Promise<Record<string, string>>
/** The base environment with the user's overlay for `agent` laid over it. */
resolveAgentEnvironment: (agent: string) => Promise<Record<string, string>>
/** The user's per-agent environment overlay from settings. */
resolveAgentLaunchEnv?: (agent: string) => Record<string, string>
resolveCommandSettings?: () => StructuredAgentCommandSettings
prepareVisuals?: PrepareNativeChatVisuals
}
/** What an agent's part reads. Each read runs once, when the part first asks, so every agent keeps
* its own order: a refusal it states before reading the directory still leaves a floating record
* unpinned. */
export type StructuredAgentLaunchBasis = {
/** Exists, belongs to this agent, runs on this host, and pins what this agent drives. */
record: AgentSessionRecord
launchDirectory(): Promise<string>
baseEnvironment(): Promise<Record<string, string>>
/** The user's overlay for this agent; empty when none. */
launchEnv(): Record<string, string>
/** The base environment with the user's overlay laid over it. */
environment(): Promise<Record<string, string>>
/** Null when the chat gets no visuals. */
visuals(): Promise<NativeChatVisualsLaunch | null>
commandSettings(): StructuredAgentCommandSettings
}
export type StructuredLaunchRequest = {
identity: AgentSessionJournalIdentity
/** The reservation's token, which the seal hands the child. */
spawnToken: string
}
export type StructuredAgentLaunchPart<L> = {
launch: Omit<L, 'cwd' | 'env' | 'envToDelete'>
/** The agent's environment, before the seal names the child. */
env: Record<string, string>
/** Inherited keys this agent's child must not keep; required, so an agent states even none. */
inheritedEnvToDelete: readonly string[]
/** See `sealStructuredSessionChild`. */
cliRuntimeCommand?: string
}
/** An agent's own part of a launch; it sees the session, never the spawn token. */
export type StructuredAgentLaunchPartResolver<L> = (
basis: StructuredAgentLaunchBasis,
identity: AgentSessionJournalIdentity
) => Promise<StructuredAgentLaunchPart<L>>
export type ComposedStructuredLaunch<L> = Omit<L, 'cwd' | 'env' | 'envToDelete'> & {
cwd: string
env: Record<string, string>
envToDelete: string[]
}
/** The child to spawn, sealed with the reservation's token. */
export type StructuredAgentLaunchResolver<L> = (
request: StructuredLaunchRequest
) => Promise<ComposedStructuredLaunch<L>>
/** The same launch for a read that starts no child: the agent's own env, unsealed. */
export type StructuredAgentLaunchReader<L> = (
identity: AgentSessionJournalIdentity
) => Promise<ComposedStructuredLaunch<L>>
function once<T>(read: () => T): () => T {
let result: { value: T } | null = null
return () => (result ??= { value: read() }).value
}
function admittedRecord(
definition: StructuredAgentDefinition,
store: StructuredAgentLaunchSources['store'],
sessionId: string
): AgentSessionRecord {
const record = store.getRecord(sessionId)
if (!record) {
throw new Error(`no durable agent-session record for ${sessionId}`)
}
if (record.provider !== definition.agent) {
throw new Error(`session ${sessionId} is a ${record.provider} session`)
}
const { location } = record
// A session pinned elsewhere belongs to that host's runtime; starting it here would put a second
// writer on the same provider session.
if (!isNativeLocalLocation(location)) {
throw new Error(
`${definition.agent} structured sessions run on the local host, not ${location.executionHostId}`
)
}
const agents = { definition: (agent: string) => (agent === definition.agent ? definition : null) }
if (!agentDrivesSession(agents, record)) {
const pin = definition.accountHomeVariable ?? definition.accountLocatorKind
throw new Error(
`session ${sessionId}'s account home or handles are not ${definition.agent}'s: its sessions pin ${pin} and hold ${definition.handleTransport} handles`
)
}
return record
}
async function composedPart<L>(
definition: StructuredAgentDefinition,
sources: StructuredAgentLaunchSources,
part: StructuredAgentLaunchPartResolver<L>,
identity: AgentSessionJournalIdentity
): Promise<StructuredAgentLaunchPart<L> & { cwd: string }> {
const record = admittedRecord(definition, sources.store, identity.sessionId)
const launchEnv = once(() => sources.resolveAgentLaunchEnv?.(definition.agent) ?? {})
const basis: StructuredAgentLaunchBasis = {
record,
launchDirectory: once(() => resolveAgentSessionLaunchDirectory(sources, record)),
baseEnvironment: once(sources.resolveBaseEnvironment),
launchEnv,
environment: once(() => sources.resolveAgentEnvironment(definition.agent)),
visuals: once(async () => (await sources.prepareVisuals?.(record.sessionId)) ?? null),
commandSettings: once(() => sources.resolveCommandSettings?.() ?? {})
}
const composed = await part(basis, identity)
return { ...composed, cwd: await basis.launchDirectory() }
}
export function composeStructuredLaunch<L>(
definition: StructuredAgentDefinition,
sources: StructuredAgentLaunchSources,
part: StructuredAgentLaunchPartResolver<L>
): StructuredAgentLaunchResolver<L> {
return async ({ identity, spawnToken }) => {
const { launch, cwd, env, inheritedEnvToDelete, cliRuntimeCommand } = await composedPart(
definition,
sources,
part,
identity
)
const child = sealStructuredSessionChild({
sessionId: identity.sessionId,
spawnToken,
env,
inheritedEnvToDelete,
...(cliRuntimeCommand ? { cliRuntimeCommand } : {})
})
return { ...launch, cwd, env: child.env, envToDelete: child.envToDelete }
}
}
export function composeStructuredLaunchRead<L>(
definition: StructuredAgentDefinition,
sources: StructuredAgentLaunchSources,
part: StructuredAgentLaunchPartResolver<L>
): StructuredAgentLaunchReader<L> {
return async (identity) => {
const { launch, cwd, env, inheritedEnvToDelete } = await composedPart(
definition,
sources,
part,
identity
)
return { ...launch, cwd, env, envToDelete: [...inheritedEnvToDelete] }
}
}
/** The shared launch inputs of the runtime an agent's adapter is built in. */
export function structuredAgentLaunchSources(
context: Pick<StructuredAgentAdapterContext, 'deps' | 'store' | 'environment'>
): StructuredAgentLaunchSources {
const { deps } = context
return {
store: context.store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveBaseEnvironment: context.environment.resolveBaseEnvironment,
resolveAgentEnvironment: context.environment.resolveAgentEnvironment,
...(deps.resolveAgentLaunchEnv ? { resolveAgentLaunchEnv: deps.resolveAgentLaunchEnv } : {}),
...(deps.resolveAgentCommandSettings
? { resolveCommandSettings: deps.resolveAgentCommandSettings }
: {}),
// Wired by the real hosts only, so a test runtime never loads the bundled skill.
...(deps.nativeChatVisuals
? {
prepareVisuals: createNativeChatVisualsDelivery({
stateDirectory: deps.stateDirectory,
logger: deps.logger,
isEnabled: deps.nativeChatVisuals.isEnabled
})
}
: {})
}
}
@@ -0,0 +1,24 @@
// A newly registered agent must get launch parity snapshots: the oracle lists its agents by hand.
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { expect, it } from 'vitest'
import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations'
const SNAPSHOT = new URL(
'./__snapshots__/structured-agent-launch-parity.test.ts.snap',
import.meta.url
)
it('snapshots the launch of every registered native-chat agent, and of no other', () => {
const snapshots = readFileSync(fileURLToPath(SNAPSHOT), 'utf8')
const snapshotted = new Set(
[...snapshots.matchAll(/^exports\[`native-chat launch parity > (\S+) /gm)].map(
([, agent]) => agent
)
)
const registered = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(
({ definition }) => definition.agent
)
expect([...snapshotted].sort()).toEqual([...registered].sort())
})
@@ -0,0 +1,664 @@
// Parity oracle for native-chat launches: the exact child every agent spawns, read at the spawn
// boundary, with production's own dependency wiring. A refactor of the launch path must leave the
// snapshot file unchanged.
import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type * as ProcessHostModule from '@orca/process-host'
import type { ProcessSpec } from '@orca/process-host/process-spec'
import type * as AcpAdapterModule from '../acp/acp-structured-session-adapter'
import type * as ClaudeAdapterModule from '../claude/claude-structured-session-adapter'
import type * as CodexAdapterModule from '../codex/codex-structured-session-adapter'
import type * as PiAdapterModule from '../pi/rpc-session-adapter'
import type * as ManagedProviderProcessModule from '../provider-process/managed-provider-process'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import type * as StructuredAgentSessionRuntimeModule from './structured-agent-session-runtime'
import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime'
const capture = vi.hoisted(() => {
const pendingEnvToDelete: readonly string[] = []
const spawns: { spec: ProcessSpec; envToDelete: readonly string[] }[] = []
const productionDeps: StructuredAgentSessionRuntimeDeps[] = []
const launches: unknown[] = []
/** What an adapter's launch resolved to; some of it (permission posture) acts only after spawn. */
const record = <T>(launch: T): T => {
launches.push(launch)
return launch
}
return { pendingEnvToDelete, spawns, productionDeps, launches, record }
})
/** Thrown in place of a real child: the start ends right where the process would begin. */
class SpawnCaptured extends Error {}
vi.mock('@orca/process-host', async (importOriginal) => {
const actual = await importOriginal<typeof ProcessHostModule>()
return {
...actual,
spawnProcess: (spec: ProcessSpec) => {
capture.spawns.push({ spec, envToDelete: capture.pendingEnvToDelete })
capture.pendingEnvToDelete = []
throw new SpawnCaptured('spawn captured')
}
}
})
vi.mock('../provider-process/managed-provider-process', async (importOriginal) => {
const actual = await importOriginal<typeof ManagedProviderProcessModule>()
return {
...actual,
spawnManagedProviderProcess: (
launch: ProviderProcessLaunch,
options: Parameters<typeof actual.spawnManagedProviderProcess>[1]
) => {
capture.pendingEnvToDelete = launch.envToDelete ?? []
return actual.spawnManagedProviderProcess(launch, options)
}
}
})
vi.mock('./structured-agent-session-runtime', async (importOriginal) => {
const actual = await importOriginal<typeof StructuredAgentSessionRuntimeModule>()
return {
...actual,
ensureStructuredAgentSessionHost: async (deps: StructuredAgentSessionRuntimeDeps) => {
capture.productionDeps.push(deps)
}
}
})
// Each adapter as production builds it, with its resolved launch recorded on the way to the spawn.
vi.mock('../codex/codex-structured-session-adapter', async (importOriginal) => {
const actual = await importOriginal<typeof CodexAdapterModule>()
class RecordingAdapter extends actual.CodexStructuredSessionAdapter {
constructor(deps: ConstructorParameters<typeof actual.CodexStructuredSessionAdapter>[0]) {
super({ ...deps, resolveLaunch: (input) => deps.resolveLaunch(input).then(capture.record) })
}
}
return { ...actual, CodexStructuredSessionAdapter: RecordingAdapter }
})
vi.mock('../claude/claude-structured-session-adapter', async (importOriginal) => {
const actual = await importOriginal<typeof ClaudeAdapterModule>()
class RecordingAdapter extends actual.ClaudeStructuredSessionAdapter {
constructor(deps: ConstructorParameters<typeof actual.ClaudeStructuredSessionAdapter>[0]) {
super({ ...deps, resolveLaunch: (input) => deps.resolveLaunch(input).then(capture.record) })
}
}
return { ...actual, ClaudeStructuredSessionAdapter: RecordingAdapter }
})
vi.mock('../acp/acp-structured-session-adapter', async (importOriginal) => {
const actual = await importOriginal<typeof AcpAdapterModule>()
class RecordingAdapter extends actual.AcpStructuredSessionAdapter {
constructor(deps: ConstructorParameters<typeof actual.AcpStructuredSessionAdapter>[0]) {
super({ ...deps, resolveLaunch: (input) => deps.resolveLaunch(input).then(capture.record) })
}
}
return { ...actual, AcpStructuredSessionAdapter: RecordingAdapter }
})
vi.mock('../pi/rpc-session-adapter', async (importOriginal) => {
const actual = await importOriginal<typeof PiAdapterModule>()
class RecordingAdapter extends actual.PiRpcSessionAdapter {
constructor(deps: ConstructorParameters<typeof actual.PiRpcSessionAdapter>[0]) {
super({ ...deps, resolveLaunch: (input) => deps.resolveLaunch(input).then(capture.record) })
}
}
return { ...actual, PiRpcSessionAdapter: RecordingAdapter }
})
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
import { setAppEnvironment } from '../../shared/app-environment'
import { AGENT_SESSION_RECORD_SCHEMA_VERSION } from '../../shared/agent-session-record'
import type { AgentSessionAccountHome, AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle'
import {
claudeProviderHandle,
codexProviderHandle
} from '../../shared/agent-session-provider-handle-encoding'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import {
ORCA_SCRUB_SAFE_LAUNCH_ENV,
ORCA_SCRUB_SAFE_PANE_ENV
} from '../../shared/agent-hook-scrub-safe-env'
import { STRUCTURED_WORKER_HANDLE_PREFIX } from '../../shared/structured-worker-handle'
import { YOLO_TUI_AGENT_ARGS } from '../../shared/tui-agent-permissions'
import {
AGENT_HOOK_RUNTIME_ENV_KEYS,
ORCA_AGENT_SESSION_CALLER_ENV_KEYS
} from '../ipc/pty/host-env/spawn-env-keys'
import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery'
import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
import { createClaudeCliFlagSupport } from '../claude/claude-cli-flag-support'
import type { AgentSessionRecordStore } from './agent-session-record-store'
import { OrcaRuntimeService } from './orca-runtime'
import {
STRUCTURED_AGENT_RUNTIME_REGISTRATIONS,
type StructuredAgentAdapterContext,
type StructuredAgentRuntimeAdapter
} from './structured-agent-runtime-registrations'
import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment'
import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups'
import {
mintStructuredWorkerPaneKey,
structuredWorkerIdentities,
structuredWorkerProcessIncarnation
} from './structured-worker-identity'
const AGENTS = ['claude', 'codex', 'grok', 'opencode', 'omp', 'pi'] as const
type Agent = (typeof AGENTS)[number]
/** What each fake binary prints for `--version`: a release every launch gate accepts. */
const VERSIONS: Record<Agent, string> = {
claude: '2.1.120 (Claude Code)',
codex: 'codex-cli 0.130.0',
grok: '1.0.50',
opencode: '2.0.20',
omp: '17.1.0',
pi: '1.1.0'
}
type LaunchCase = {
agent: Agent
workspace: 'worktree' | 'floating'
start: 'fresh' | 'resumed'
/** Registered as a dispatched worker, with the chat's visuals setting off. */
worker: boolean
/** Every launch setting a user can change, set away from its default. */
configured: boolean
}
const CASES: readonly LaunchCase[] = AGENTS.flatMap((agent) => [
...(['worktree', 'floating'] as const).flatMap((workspace) =>
(['fresh', 'resumed'] as const).map((start) => ({
agent,
workspace,
start,
worker: false,
configured: false
}))
),
{
agent,
workspace: 'worktree' as const,
start: 'fresh' as const,
worker: true,
configured: false
}
]).concat(
// Last, so every case before them keeps its session id.
AGENTS.flatMap((agent) =>
(['fresh', 'resumed'] as const).map((start) => ({
agent,
workspace: 'worktree' as const,
start,
worker: false,
configured: true
}))
)
)
const SPAWN_TOKEN = 'parity-spawn-token'
const WORKSPACE_ID = 'parity-worktree'
function caseName(launch: LaunchCase): string {
const suffix = `${launch.worker ? ' worker' : ''}${launch.configured ? ' configured' : ''}`
return `${launch.agent} ${launch.workspace} ${launch.start}${suffix}`
}
function sessionIdFor(index: number): string {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`
}
let root = ''
let settings: Partial<GlobalSettings> = {}
let deps: StructuredAgentSessionRuntimeDeps
const records = new Map<string, AgentSessionRecord>()
const pins: { sessionId: string; directory: string }[] = []
const savedEnv = process.env
async function writeExecutable(path: string, script: string): Promise<void> {
await writeFile(path, `#!/bin/sh\n${script}\n`)
await chmod(path, 0o755)
}
/** A fake install: every agent's binary answers `--version`, beside a `node` (Claude's runtime). */
async function installFakeBinaries(bin: string): Promise<void> {
await mkdir(bin, { recursive: true })
await mkdir(join(root, 'custom-bin'), { recursive: true })
for (const agent of AGENTS) {
await writeExecutable(
join(bin, agent),
`if [ "$1" = "--version" ]; then echo "${VERSIONS[agent]}"; exit 0; fi\nexit 1`
)
// What a Command setting names instead of the agent's own binary.
await writeExecutable(
join(root, 'custom-bin', `${agent}-custom`),
`if [ "$1" = "--version" ]; then echo "${VERSIONS[agent]}"; exit 0; fi\nexit 1`
)
}
await writeExecutable(join(bin, 'node'), 'exit 0')
}
/** The settings every default case runs with. */
function defaultSettings(): Partial<GlobalSettings> {
return {
agentDefaultArgs: {
claude: '--add-dir /parity/extra',
codex: '-c model_reasoning_effort=high'
},
agentDefaultEnv: Object.fromEntries(AGENTS.map((agent) => [agent, { PARITY_OVERLAY: agent }])),
nativeChatInlineVisuals: true
} satisfies Partial<GlobalSettings>
}
/** A Command setting per agent, permissions flipped from each agent's default posture, an overlay
* over inherited and shell keys, and only named shell variables inherited. */
function configuredSettings(): Partial<GlobalSettings> {
return {
agentCmdOverrides: Object.fromEntries(
AGENTS.map((agent) => [agent, join(root, 'custom-bin', `${agent}-custom`)])
),
agentDefaultArgs: {
claude: `${YOLO_TUI_AGENT_ARGS.claude} --add-dir /parity/configured`,
codex: `${YOLO_TUI_AGENT_ARGS.codex} -c model_reasoning_effort=low`,
grok: '',
opencode: '',
omp: '',
pi: ''
},
agentDefaultEnv: Object.fromEntries(
AGENTS.map((agent) => [
agent,
{ PARITY_OVERLAY: `${agent}-configured`, INHERITED_ONLY: 'overlay', LANG: 'C' }
])
),
nativeChatInheritShellEnvironment: false,
nativeChatShellEnvironmentVariables: ['SHELL_ONLY']
} satisfies Partial<GlobalSettings>
}
/** The live settings the host reads for `launch`. */
function settingsFor(launch: LaunchCase): Partial<GlobalSettings> {
return {
...defaultSettings(),
...(launch.configured ? configuredSettings() : {}),
nativeChatInlineVisuals: !launch.worker
}
}
/** Every pane, hook, caller, handle, visuals and auth key a parent Orca pane could hand down. */
function inheritedEnvironment(): Record<string, string> {
const env: Record<string, string> = {
PATH: `${join(root, 'bin')}:/usr/bin:/bin`,
HOME: join(root, 'home'),
LANG: 'en_US.UTF-8',
INHERITED_ONLY: 'process',
ORCA_PANE_KEY: 'tab_parent:parent-pane',
ORCA_TAB_ID: 'tab_parent',
ORCA_WORKTREE_ID: 'parent-worktree',
ORCA_AGENT_LAUNCH_TOKEN: 'parent-launch-token',
[ORCA_SCRUB_SAFE_PANE_ENV]: 'parent-scrub-safe-pane',
[ORCA_SCRUB_SAFE_LAUNCH_ENV]: 'parent-scrub-safe-launch',
ORCA_TERMINAL_HANDLE: 'term_parent',
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'parent-spawn-token',
[NATIVE_CHAT_VISUALS_DIR_ENV]: '/parent/visuals',
ORCA_CLI_COMMAND: '/parent/orca',
ORCA_CLI_BIN_DIR: '/parent/bin',
ORCA_USER_DATA_PATH: '/parent/user-data',
ORCA_OPENCODE_CONFIG_DIR: '/parent/opencode-overlay',
ORCA_DATA_ACCOUNT_PROVIDER: 'parent-account',
CLAUDE_CONFIG_DIR: '/parent/claude',
CLAUDE_CODE_SESSION_ID: 'parent-claude-session',
ANTHROPIC_API_KEY: 'parent-api-key',
CODEX_HOME: '/parent/codex',
NODE_OPTIONS: '--max-old-space-size=4096'
}
for (const key of [...AGENT_HOOK_RUNTIME_ENV_KEYS, ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS]) {
env[key] = `parent-${key.toLowerCase()}`
}
return env
}
function accountHomeFor(agent: Agent): AgentSessionAccountHome {
const directory = (name: string) => join(root, 'accounts', name)
switch (agent) {
case 'claude':
return { variable: 'CLAUDE_CONFIG_DIR', path: directory('claude') }
case 'codex':
return { variable: 'CODEX_HOME', path: directory('codex') }
case 'grok':
return { variable: 'GROK_HOME', path: directory('grok') }
case 'opencode':
return { kind: 'opencode', locator: { kind: 'unmanaged' } }
case 'omp':
return { variable: 'PI_CODING_AGENT_DIR', path: directory('omp') }
case 'pi':
return { variable: 'PI_CODING_AGENT_DIR', path: directory('pi') }
}
}
async function resumedHandleFor(
agent: Agent,
sessionId: string,
launchDirectory: string
): Promise<AgentSessionProviderHandle> {
switch (agent) {
case 'claude':
return claudeProviderHandle(sessionId, '11111111-1111-4111-8111-111111111111')
case 'codex':
return codexProviderHandle(`thread-${sessionId}`)
case 'pi': {
const file = join(root, 'pi-sessions', `${sessionId}.jsonl`)
await mkdir(join(root, 'pi-sessions'), { recursive: true })
await writeFile(file, `${JSON.stringify({ type: 'session', cwd: launchDirectory })}\n`)
return { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }
}
case 'grok':
case 'opencode':
case 'omp':
return { transport: 'acp', agent, nativeId: `acp-session-${sessionId}` }
}
}
async function recordFor(
launch: LaunchCase,
sessionId: string
): Promise<{
record: AgentSessionRecord
handle: AgentSessionProviderHandle | null
}> {
const floating = launch.workspace === 'floating'
const directory = floating ? join(root, 'floating-pinned') : join(root, 'worktree')
// A resumed floating chat is held to its pin; a fresh one is pinned by its first launch.
const pinned = floating && launch.start === 'resumed'
const handle =
launch.start === 'resumed' ? await resumedHandleFor(launch.agent, sessionId, directory) : null
const record: AgentSessionRecord = {
schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION,
sessionId,
provider: launch.agent,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: floating ? FLOATING_TERMINAL_WORKTREE_ID : WORKSPACE_ID,
workspaceKind: 'folder'
},
providerHandleChain: handle
? [
{
linkId: 'link-1',
handle,
origin: 'created',
mintedAtFence: 1,
observedAt: 1_000
}
]
: [],
accountHome: accountHomeFor(launch.agent),
...(pinned ? { launchDirectory: directory } : {}),
lease: {
sessionId,
runtimeKind: 'native',
runtimeFence: 1,
handoffStage: null,
provenHandleLinkId: null,
ownerProcess: null,
reservedSpawnToken: SPAWN_TOKEN,
leaseDeadlineAt: 0,
lastRenewedAt: 0,
handoffOperationId: null,
journalCheckpoint: null,
claimKeyId: 'parity-key',
claimStatus: 'released',
unreconciled: false,
deathEvidence: null
},
createdAt: 1_000,
updatedAt: 1_000
}
return { record, handle }
}
function eventSink(): StructuredAgentSessionEventSink {
return {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendTransition: () => ({ accepted: true }),
journalItems: () => null
}
}
function adapterContext(): StructuredAgentAdapterContext {
const store = {
getRecord: (sessionId: string) => records.get(sessionId) ?? null,
pinLaunchDirectory: async (sessionId: string, directory: string) => {
pins.push({ sessionId, directory })
const record = records.get(sessionId)
if (record) {
records.set(sessionId, { ...record, launchDirectory: directory })
}
},
listVisibleSessionIds: () => []
}
return {
deps,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a launch reads records and pins a floating directory; nothing else of the store runs before the spawn.
store: store as unknown as AgentSessionRecordStore,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the journal is read only after a start succeeds, and every start here ends at its spawn.
journalDatabase: { db: null } as unknown as JournalHostDatabase,
environment: createStructuredAgentEnvironmentResolvers(deps),
deliverLifecycle: () => {},
followUps: createStructuredAgentSessionDispatchFollowUps({
host: () => null,
logger: deps.logger
}),
host: () => null
}
}
const POSTURE_KEYS = new Set([
'permissionPolicy',
'fullAccess',
'permissionMode',
'allowDangerouslySkipPermissions'
])
/** A launch's permission posture: Codex's thread policy and ACP's full access act after spawn. */
function postureOf(launch: unknown): Record<string, unknown> {
const entries = typeof launch === 'object' && launch !== null ? Object.entries(launch) : []
const options = entries.find(([key]) => key === 'options')?.[1]
const nested = typeof options === 'object' && options !== null ? Object.entries(options) : []
return Object.fromEntries([...entries, ...nested].filter(([key]) => POSTURE_KEYS.has(key)))
}
/** Paths under the run's temp root and this process's own binary read the same on every run. */
function normalized(value: string): string {
return value
.split(root)
.join('<root>')
.split(process.execPath)
.join('<node>')
.split(process.cwd())
.join('<repo>')
}
/** Keys whose value is the installed SDK's own, which a dependency bump changes. */
const DEPENDENCY_VALUES = new Set(['CLAUDE_AGENT_SDK_VERSION'])
function sortedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries(env)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, DEPENDENCY_VALUES.has(key) ? '<dependency>' : normalized(value)])
)
}
/** The child as the provider sees it: the supervisor's wrapper unwrapped, its spec decoded. */
function childOf(capture: { spec: ProcessSpec; envToDelete: readonly string[] }) {
const { spec } = capture
const env = { ...spec.env }
const encoded = env.ORCA_PROVIDER_SUPERVISOR_SPEC
delete env.ORCA_PROVIDER_SUPERVISOR_SPEC
delete env.ELECTRON_RUN_AS_NODE
const supervised = spec.args?.[0] === '-e' && encoded !== undefined
const argv = supervised ? (spec.args ?? []).slice((spec.args ?? []).indexOf('--') + 1) : []
const supervisor: Record<string, unknown> | null = supervised
? JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'))
: null
return {
command: normalized(supervised ? (argv[0] ?? '') : spec.program),
args: (supervised ? argv.slice(1) : (spec.args ?? [])).map(normalized),
cwd: normalized(spec.cwd ?? ''),
env: sortedEnv(env),
envToDelete: [...new Set(capture.envToDelete)].sort(),
supervisor: supervisor
? {
cwd: normalized(String(supervisor.cwd)),
closeRequest: supervisor.closeRequest,
lifetime: supervisor.lifetime,
nodeEnv: supervisor.nodeEnv
}
: null
}
}
function installAppEnvironment(): void {
setAppEnvironment({
getPath: (name) => join(root, name === 'userData' ? 'user-data' : name),
getAppPath: () => process.cwd(),
getVersion: () => '1.0.0',
isPackaged: () => false,
getCliLauncherPath: () => join(root, 'orca-cli', 'bin', 'orca'),
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => []
})
}
beforeAll(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-launch-parity-')))
await installFakeBinaries(join(root, 'bin'))
// Loaded first: the SDK writes into this process's environment once, when imported.
await import('@anthropic-ai/claude-agent-sdk')
for (const directory of ['worktree', 'floating', 'floating-pinned', 'home', 'state']) {
await mkdir(join(root, directory), { recursive: true })
}
process.env = inheritedEnvironment()
installAppEnvironment()
settings = defaultSettings()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host wiring reads only the store's getSettings method.
const runtime = new OrcaRuntimeService({
getSettings: () => settings
} as never)
await runtime.ensureStructuredAgentSessionHost()
const production = capture.productionDeps.at(-1)
if (!production) {
throw new Error('the runtime installed no structured host')
}
deps = {
...production,
stateDirectory: join(root, 'state'),
logger: { warn: () => {}, error: () => {} },
resolveWorkspacePath: async (workspaceId) =>
join(root, workspaceId === FLOATING_TERMINAL_WORKTREE_ID ? 'floating' : 'worktree'),
// The login shell's variables, read once per host like production's snapshot: its own PATH,
// one variable the configured cases name and one they do not.
resolveEnvironment: async () => ({
PATH: `${join(root, 'bin')}:/opt/login-shell/bin:/usr/bin:/bin`,
HOME: join(root, 'home'),
LANG: 'en_US.UTF-8',
SHELL_ONLY: 'shell',
SHELL_UNLISTED: 'shell'
}),
// A version answered at once, so a slow machine cannot drop a version-gated flag.
claudeCliFlags: createClaudeCliFlagSupport({
probe: async () => VERSIONS.claude,
keyOf: async (command, cwd) => `${command}\n${cwd}`,
budgetMs: 60_000,
now: () => 0
})
}
})
// Again after the shared setup's own per-test environment, so this app's CLI paths are the run's;
// and each case inherits the same environment whatever ran before it.
beforeEach(() => {
installAppEnvironment()
process.env = inheritedEnvironment()
})
afterAll(async () => {
process.env = savedEnv
await rm(root, { recursive: true, force: true })
})
describe.skipIf(process.platform === 'win32')('native-chat launch parity', () => {
it.each(CASES.map((launch, index) => [caseName(launch), launch, index] as const))(
'%s',
async (_name, launch, index) => {
const registration = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.find(
({ definition }) => definition.agent === launch.agent
)
if (!registration) {
throw new Error(`no registration for ${launch.agent}`)
}
const sessionId = sessionIdFor(index)
const { record, handle } = await recordFor(launch, sessionId)
records.set(sessionId, record)
settings = settingsFor(launch)
const workerHandle = `${STRUCTURED_WORKER_HANDLE_PREFIX}${sessionId}`
if (launch.worker) {
structuredWorkerIdentities.register({
handle: workerHandle,
sessionId,
agent: launch.agent,
paneKey: mintStructuredWorkerPaneKey(sessionId),
processIncarnation: structuredWorkerProcessIncarnation(sessionId),
worktreeId: WORKSPACE_ID,
hostScope: { kind: 'local', hostId: 'local' }
})
}
const adapter: StructuredAgentRuntimeAdapter = registration.createAdapter(adapterContext())
const input: StructuredAgentSessionAcquireInput = {
identity: {
sessionId,
workspaceId: record.location.workspaceId,
hostId: 'local',
agent: launch.agent,
providerHandle: handle
},
fence: 1,
spawnToken: SPAWN_TOKEN,
events: eventSink()
}
capture.spawns.length = 0
capture.launches.length = 0
pins.length = 0
try {
const outcome = await adapter.acquire(input).then(
() => null,
(error: unknown) => error
)
expect(capture.spawns, String(outcome)).toHaveLength(1)
expect(capture.launches).toHaveLength(1)
const [spawn] = capture.spawns
expect({
child: childOf(spawn!),
launchPosture: postureOf(capture.launches[0]),
pinnedLaunchDirectories: pins.map(({ directory }) => normalized(directory))
}).toMatchSnapshot()
} finally {
structuredWorkerIdentities.forget(workerHandle)
await adapter.closeAll().catch(() => {})
}
}
)
})
@@ -1,3 +1,4 @@
import { createAcpStructuredLaunchResolver } from './structured-agent-launch-composition.test-support'
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -5,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { acpLaunchSpecFor } from '../acp/acp-launch-specs'
import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution'
import { structuredAgentSupportsLaunch } from './structured-agent-launch-support'
const { loginShell } = vi.hoisted(() => {
@@ -1,3 +1,4 @@
import { createAcpStructuredLaunchResolver } from './structured-agent-launch-composition.test-support'
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -5,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { acpLaunchSpecFor } from '../acp/acp-launch-specs'
import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution'
import { structuredAgentSupportsLaunch } from './structured-agent-launch-support'
const { loginShell } = vi.hoisted(() => {
@@ -1,10 +1,10 @@
import { createAcpStructuredLaunchResolver } from './structured-agent-launch-composition.test-support'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { acpLaunchSpecFor } from '../acp/acp-launch-specs'
import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution'
import { structuredAgentSupportsLaunch } from './structured-agent-launch-support'
const { probeAgentCliVersion, loginShell } = vi.hoisted(() => {
@@ -33,8 +33,7 @@ function context(): StructuredAgentModelCatalogContext {
deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'],
environment: {
resolveBaseEnvironment: unused,
resolveCodexEnvironment: unused,
resolveClaudeInheritedEnv: unused
resolveAgentEnvironment: unused
}
}
}
@@ -118,8 +117,7 @@ describe('the model catalog contract on every registration', () => {
} as StructuredAgentModelCatalogContext['deps'],
environment: {
resolveBaseEnvironment: env,
resolveCodexEnvironment: env,
resolveClaudeInheritedEnv: env
resolveAgentEnvironment: env
}
})
const variables = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME', grok: 'GROK_HOME' }
@@ -7,6 +7,15 @@ import { createClaudeModelCatalogProbe } from '../claude/claude-model-catalog-pr
import { createCodexModelCatalogProbe } from '../codex/codex-model-catalog-probe'
import type { AgentModelCatalogDiscovery } from '../native-chat/agent-model-catalog/agent-model-catalog-discovery'
import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations'
import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime'
/** Claude's overlay, laid over its base env minus an inherited config dir; absent when none. */
export function claudeLaunchEnvOverlay(
deps: Pick<StructuredAgentSessionRuntimeDeps, 'resolveAgentLaunchEnv'>
): { resolveEnv?: () => Record<string, string> } {
const resolve = deps.resolveAgentLaunchEnv
return resolve ? { resolveEnv: () => resolve('claude') } : {}
}
export function codexModelCatalogDiscovery({
deps,
@@ -21,7 +30,7 @@ export function codexModelCatalogDiscovery({
...(deps.prepareCodexCatalogProbeHome
? { prepareHome: deps.prepareCodexCatalogProbeHome }
: {}),
resolveEnvironment: environment.resolveCodexEnvironment,
resolveEnvironment: () => environment.resolveAgentEnvironment('codex'),
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
})
}
@@ -36,10 +45,10 @@ export function claudeModelCatalogDiscovery({
// Claude's settings or env can pick a model other than the one its listing recommends.
listingNamesConfiguredModel: false,
probe: createClaudeModelCatalogProbe({
resolveInheritedEnv: environment.resolveClaudeInheritedEnv,
resolveInheritedEnv: environment.resolveBaseEnvironment,
resolveAuthPolicy: deps.resolveClaudeAuthPolicy,
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
...claudeLaunchEnvOverlay(deps)
})
}
}
@@ -35,8 +35,7 @@ function context(): StructuredAgentModelCatalogContext {
deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'],
environment: {
resolveBaseEnvironment: unused,
resolveCodexEnvironment: unused,
resolveClaudeInheritedEnv: unused
resolveAgentEnvironment: unused
}
}
}
@@ -1,6 +1,7 @@
// Each runtime registration owns its adapter, location support and account resolution at start.
import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution'
import { codexStructuredLaunchPart } from '../codex/codex-structured-launch-resolution'
import { claudeStructuredLaunchPart } from '../claude/claude-structured-launch-resolution'
import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support'
import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support'
import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location'
@@ -23,10 +24,6 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wir
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references'
import {
createNativeChatVisualsDelivery,
type PrepareNativeChatVisuals
} from '../native-chat/native-chat-visuals-delivery'
import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
import { replayJournal } from '../native-chat/agent-session-journal/journal-open'
import type { AgentSessionRecordStore } from './agent-session-record-store'
@@ -45,7 +42,7 @@ import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-defini
import { createAcpAgentConnection } from '../acp/acp-agent-connection'
import {
acpLaunchVersionSupported,
createAcpStructuredLaunchResolver,
acpStructuredLaunchPart,
resolveAcpLaunchCommand
} from '../acp/acp-structured-launch-resolution'
import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter'
@@ -53,9 +50,15 @@ import { PI_RPC_RUNTIME_REGISTRATION } from '../pi/rpc-runtime-registration'
import type { AgentModelCatalogDiscovery } from '../native-chat/agent-model-catalog/agent-model-catalog-discovery'
import {
acpModelCatalogDiscovery,
claudeLaunchEnvOverlay,
claudeModelCatalogDiscovery,
codexModelCatalogDiscovery
} from './structured-agent-model-catalog-discovery'
import {
composeStructuredLaunch,
composeStructuredLaunchRead,
structuredAgentLaunchSources
} from './structured-agent-launch-composition'
/** What an agent's adapter is built from: the open store and the runtime around it. */
export type StructuredAgentAdapterContext = {
@@ -124,35 +127,21 @@ export type StructuredAgentRuntimeRegistration = {
) => Promise<AgentSessionAccountHome>
}
function nativeChatVisualsFor(deps: StructuredAgentSessionRuntimeDeps): {
prepareVisuals?: PrepareNativeChatVisuals
} {
return deps.nativeChatVisuals
? {
prepareVisuals: createNativeChatVisualsDelivery({
stateDirectory: deps.stateDirectory,
logger: deps.logger,
isEnabled: deps.nativeChatVisuals.isEnabled
})
}
: {}
}
function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter {
const { deps, store, followUps, host } = context
const { deps, followUps, host } = context
return new CodexStructuredSessionAdapter({
resolveAccountKind: deps.resolveCodexAccountKind,
resolveLaunch: createCodexStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveEnvironment: context.environment.resolveCodexEnvironment,
resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'),
...(deps.resolveCodexPermissionPolicy
? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy }
: {}),
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}),
...nativeChatVisualsFor(deps)
}),
resolveLaunch: composeStructuredLaunch(
CODEX_STRUCTURED_AGENT,
structuredAgentLaunchSources(context),
codexStructuredLaunchPart({
resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'),
...(deps.resolveCodexPermissionPolicy
? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy }
: {}),
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
})
),
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
modelCatalog: agentModelCatalogStore,
@@ -174,20 +163,28 @@ function createClaudeAdapter(
context: StructuredAgentAdapterContext
): StructuredAgentRuntimeAdapter {
const { deps, store, followUps, host } = context
const overlay = claudeLaunchEnvOverlay(deps).resolveEnv
return createStructuredClaudeRuntimeAdapter({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveLaunch: composeStructuredLaunch(
CLAUDE_STRUCTURED_AGENT,
structuredAgentLaunchSources(context),
claudeStructuredLaunchPart({
resolveLaunchArgs: () => deps.resolveLaunchArgs('claude'),
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
resolveAuthPolicy: deps.resolveClaudeAuthPolicy,
...(deps.resolveClaudePermissionMode
? { resolvePermissionMode: deps.resolveClaudePermissionMode }
: {}),
attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory),
...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {})
})
),
...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}),
...(deps.claudeCliFlags ? { claudeCliFlags: deps.claudeCliFlags } : {}),
...nativeChatVisualsFor(deps),
...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}),
resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv,
resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs('claude'),
resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy,
...(deps.resolveClaudePermissionMode
? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode }
: {}),
attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory),
...(overlay ? { resolveClaudeLaunchEnv: overlay } : {}),
resolveClaudeInheritedEnv: context.environment.resolveBaseEnvironment,
onLifecycleEvent: context.deliverLifecycle,
logger: deps.logger,
onChildWorkEvidence: (sessionId, evidence) =>
@@ -224,27 +221,21 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio
resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }),
modelCatalog: (context) => acpModelCatalogDiscovery(spec, context),
createAdapter: (context) => {
const { deps, store, followUps } = context
const { deps, followUps } = context
const readJournal = (sessionId: string) =>
replayJournal(context.journalDatabase.db, sessionId)
const definition = acpStructuredAgentDefinition(spec)
const sources = structuredAgentLaunchSources(context)
const part = acpStructuredLaunchPart(spec, {
readJournal,
...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}),
logger: deps.logger
})
return new AcpStructuredSessionAdapter({
spec,
readJournal,
resolveLaunch: createAcpStructuredLaunchResolver(spec, {
store,
readJournal,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveEnvironment: context.environment.resolveBaseEnvironment,
...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}),
...(deps.resolveAgentCommandSettings
? { resolveCommandSettings: deps.resolveAgentCommandSettings }
: {}),
...(deps.resolveAgentFullAccess
? { resolveFullAccess: deps.resolveAgentFullAccess }
: {}),
...nativeChatVisualsFor(deps),
logger: deps.logger
}),
resolveLaunch: composeStructuredLaunch(definition, sources, part),
readLaunch: composeStructuredLaunchRead(definition, sources, part),
connect: (launch, options) => createAcpAgentConnection(launch, options),
onChildWorkEvidence: (sessionId, evidence) =>
context.host()?.publishChildWorkEvidence(sessionId, evidence),
@@ -357,7 +357,10 @@ beforeEach(async () => {
}
},
resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy,
resolveCodexOverrides: () => {
resolveAgentLaunchEnv: (agent): Record<string, string> => {
if (agent !== 'codex') {
return {}
}
codexOverrideReads += 1
return { CODEX_PROFILE: configuredCodexProfile }
},
@@ -118,9 +118,6 @@ export type StructuredAgentSessionRuntimeDeps = {
readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime']
/** Required, and asserted at install time — saved Arguments must never be silently omitted. */
resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise<string[]> | string[]
resolveLaunchEnv?: () => Promise<NodeJS.ProcessEnv>
resolveLaunchEnvOverlay?: () => Promise<Record<string, string>> | Record<string, string>
resolveClaudeLaunchEnv?: () => Promise<Record<string, string>> | Record<string, string>
/** Required, and asserted at install time — an absent policy must not degrade to a guess. */
resolveClaudeAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
/** The user's Agent Permissions setting for Claude; absent means prompting. */
@@ -129,14 +126,13 @@ export type StructuredAgentSessionRuntimeDeps = {
resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy
/** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */
resolveAgentFullAccess?: (agent: string) => boolean
/** The user's per-agent environment overlay, for agents with no lane-specific resolver. */
/** The user's per-agent environment overlay from settings, laid over every agent's base env. */
resolveAgentLaunchEnv?: (agent: string) => Record<string, string>
/** The settings a per-agent Command override is read from, for the same agents. */
resolveAgentCommandSettings?: () => StructuredAgentCommandSettings
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
/** Which login-shell variables Codex and Claude children inherit; absent inherits all. */
resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy
resolveCodexOverrides?: () => NodeJS.ProcessEnv
/** Where every failure the runtime and its host carry on past is reported. Required, so no path
* can drop one: the desktop and headless hosts both pass the trace-file logger. */
logger: StructuredAgentSessionLogger
@@ -8,7 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import type { AgentSessionRecordStore } from './agent-session-record-store'
import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness'
import type { AgentSessionReserveRequest } from './agent-session-reservation-admission'
@@ -50,7 +50,10 @@ const SESSION = 'session-alpha'
const MINTED_TOKEN = 'spawn-minted-by-this-store'
function environ(token: string | null): string {
return ['PATH=/usr/bin', ...(token ? [`${CODEX_SPAWN_TOKEN_ENV}=${token}`] : [])].join('\0')
return [
'PATH=/usr/bin',
...(token ? [`${ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV}=${token}`] : [])
].join('\0')
}
function reserveRequest(): AgentSessionReserveRequest {
@@ -68,17 +68,22 @@ describe('structuredAgentBaseEnvironment', () => {
})
describe('createStructuredAgentEnvironmentResolvers', () => {
it('gives Codex and Claude the same base, with overlays on Codex only', async () => {
it("gives every agent the same base, with each agent's own overlay on it", async () => {
const resolvers = createStructuredAgentEnvironmentResolvers({
resolveEnvironment: async () => ({ PATH: '/shell/bin', SHELL_ONLY: '1' }),
resolveShellEnvironmentPolicy: () => INHERIT_ALL,
resolveCodexOverrides: () => ({ CODEX_PROFILE: 'p' })
resolveAgentLaunchEnv: (agent): Record<string, string> =>
agent === 'codex' ? { CODEX_PROFILE: 'p' } : {}
})
expect(await resolvers.resolveClaudeInheritedEnv()).toEqual({
expect(await resolvers.resolveBaseEnvironment()).toEqual({
PATH: '/shell/bin',
SHELL_ONLY: '1'
})
expect(await resolvers.resolveCodexEnvironment()).toEqual({
expect(await resolvers.resolveAgentEnvironment('claude')).toEqual({
PATH: '/shell/bin',
SHELL_ONLY: '1'
})
expect(await resolvers.resolveAgentEnvironment('codex')).toEqual({
PATH: '/shell/bin',
SHELL_ONLY: '1',
CODEX_PROFILE: 'p'
@@ -90,23 +90,22 @@ export async function resolveHostAgentBaseEnvironment(
export type StructuredAgentEnvironmentSources = {
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy
resolveLaunchEnv?: () => Promise<NodeJS.ProcessEnv>
resolveLaunchEnvOverlay?: () => Promise<Record<string, string>> | Record<string, string>
resolveCodexOverrides?: () => NodeJS.ProcessEnv
/** The user's per-agent environment overlay from settings. */
resolveAgentLaunchEnv?: (agent: string) => Record<string, string>
}
/**
* Both providers' child envs over one login-shell snapshot, taken once at install.
* Every agent's child env over one login-shell snapshot, taken once at install.
* The policy and overlays are re-read per acquisition, so a settings change reaches
* the next chat without a restart.
*/
export function createStructuredAgentEnvironmentResolvers(
sources: StructuredAgentEnvironmentSources
): {
resolveCodexEnvironment: () => Promise<NodeJS.ProcessEnv>
resolveClaudeInheritedEnv: () => Promise<Record<string, string>>
/** The shared base every agent's child env starts from, before its own overlay. */
resolveBaseEnvironment: () => Promise<Record<string, string>>
/** The base with the user's overlay for `agent` laid over it. */
resolveAgentEnvironment: (agent: string) => Promise<Record<string, string>>
} {
const shellEnvironment = (sources.resolveEnvironment ?? resolveLoginShellEnvironment)()
const resolveBase = async (): Promise<Record<string, string>> =>
@@ -115,13 +114,10 @@ export function createStructuredAgentEnvironmentResolvers(
policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null)
})
return {
resolveCodexEnvironment: async () => ({
resolveBaseEnvironment: resolveBase,
resolveAgentEnvironment: async (agent) => ({
...(await resolveBase()),
...(await sources.resolveLaunchEnv?.()),
...(await sources.resolveLaunchEnvOverlay?.()),
...sources.resolveCodexOverrides?.()
}),
resolveClaudeInheritedEnv: resolveBase,
resolveBaseEnvironment: resolveBase
...sources.resolveAgentLaunchEnv?.(agent)
})
}
}
@@ -1,44 +1,27 @@
// The chat attachment store reaches Claude's launch as an added directory: the runtime hands its
// root to the Claude adapter, and the adapter to the launch resolver. Drop either hand-off and
// Claude asks before reading every attached document.
// root to Claude's part of the launch its registration composes. Drop that hand-off and Claude asks
// before reading every attached document.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as LaunchResolution from '../claude/claude-structured-launch-resolution'
import type * as ClaudeRuntimeAdapter from './structured-claude-runtime-adapter'
type GrantDeps = { attachmentDirectory?: string }
const captured = vi.hoisted(() => {
const adapter: GrantDeps[] = []
const resolver: GrantDeps[] = []
return { adapter, resolver }
})
vi.mock('./structured-claude-runtime-adapter', async (importOriginal) => {
const actual = await importOriginal<typeof ClaudeRuntimeAdapter>()
return {
...actual,
createStructuredClaudeRuntimeAdapter: (
deps: Parameters<typeof actual.createStructuredClaudeRuntimeAdapter>[0]
) => {
captured.adapter.push(deps)
return actual.createStructuredClaudeRuntimeAdapter(deps)
}
}
const part: GrantDeps[] = []
return { part }
})
vi.mock('../claude/claude-structured-launch-resolution', async (importOriginal) => {
const actual = await importOriginal<typeof LaunchResolution>()
return {
...actual,
createClaudeStructuredLaunchResolver: (
deps: Parameters<typeof actual.createClaudeStructuredLaunchResolver>[0]
) => {
captured.resolver.push(deps)
return actual.createClaudeStructuredLaunchResolver(deps)
claudeStructuredLaunchPart: (deps: Parameters<typeof actual.claudeStructuredLaunchPart>[0]) => {
captured.part.push(deps)
return actual.claudeStructuredLaunchPart(deps)
}
}
})
@@ -61,7 +44,7 @@ afterEach(async () => {
})
describe("structured Claude's read grant for chat attachments", () => {
it('hands the store root from the runtime through the adapter to the launch resolver', async () => {
it("hands the store root from the runtime to Claude's part of the launch", async () => {
stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-grant-'))
const directory = stateDirectory
await ensureStructuredAgentSessionHost({
@@ -76,7 +59,6 @@ describe("structured Claude's read grant for chat attachments", () => {
})
const root = agentSessionAttachmentStoreRoot(directory)
expect(captured.adapter.at(-1)?.attachmentDirectory).toBe(root)
expect(captured.resolver.at(-1)?.attachmentDirectory).toBe(root)
expect(captured.part.at(-1)?.attachmentDirectory).toBe(root)
})
})
@@ -24,6 +24,7 @@ import {
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import type { AgentSessionRecordStore } from './agent-session-record-store'
import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness'
import { createClaudeStructuredLaunchResolver } from './structured-agent-launch-composition.test-support'
import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter'
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
@@ -116,9 +117,12 @@ beforeEach(async () => {
adapter = createStructuredClaudeRuntimeAdapter({
store,
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveClaudeCommand: () => '/usr/local/bin/claude',
resolveClaudeLaunchArgs: () => [],
resolveClaudeAuthPolicy: () => ({ account: 'system' }),
resolveLaunch: createClaudeStructuredLaunchResolver({
store,
resolveCommand: () => '/usr/local/bin/claude',
resolveLaunchArgs: () => [],
resolveAuthPolicy: () => ({ account: 'system' })
}),
openClaudeConnection: claude.openConnection,
readProcessStartTime: async () => HOST_TEST_NOW,
onLifecycleEvent: () => {}
@@ -1,9 +1,6 @@
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { resolveClaudeCommand } from '../codex-cli/command'
import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution'
import {
ClaudeStructuredSessionAdapter,
type ClaudeStructuredSessionAdapterDeps
@@ -20,27 +17,19 @@ import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands'
import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection'
import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support'
import { prewarmClaudeCliFlags } from '../claude/claude-cli-flag-prewarm'
import type { PrepareNativeChatVisuals } from '../native-chat/native-chat-visuals-delivery'
export type StructuredClaudeRuntimeAdapterDeps = {
store: AgentSessionRecordStore
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveClaudeLaunchArgs: () => Promise<string[]> | string[]
/** The registration's composed launch: the shared launch path with Claude's part. */
resolveLaunch: ClaudeStructuredSessionAdapterDeps['resolveLaunch']
/** The binary and env the startup version prewarm asks, as a launch would. */
resolveClaudeCommand?: () => string
/** Which version-gated flags a Claude CLI takes; absent never passes one. */
claudeCliFlags?: ClaudeCliFlagSupport
/** Each chat's visuals folder and skill; absent leaves chats without visuals. */
prepareVisuals?: PrepareNativeChatVisuals
resolveClaudeLaunchEnv?: () => Promise<Record<string, string>> | Record<string, string>
/** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */
resolveClaudeInheritedEnv?: () => Promise<Record<string, string>>
/** Managed-account auth state for a Claude launch, mirroring the terminal preflight.
* Required: an absent policy is what silently under-strips. */
resolveClaudeAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
/** The user's Agent Permissions setting for Claude; absent means prompting. */
resolveClaudePermissionMode?: () => Promise<PermissionMode> | PermissionMode
/** Where the host stores chat attachments; granted to the agent as a readable directory. */
attachmentDirectory?: string
/** Which version-gated flags a Claude CLI takes; absent never passes one. */
claudeCliFlags?: ClaudeCliFlagSupport
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime']
onLifecycleEvent: (event: StructuredAgentSessionLifecycleEvent) => void
@@ -105,23 +94,7 @@ export function createStructuredClaudeRuntimeAdapter(
atRestCommands: new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: deps.resolveWorkspacePath
}),
resolveLaunch: createClaudeStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveLaunchArgs: deps.resolveClaudeLaunchArgs,
resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand,
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}),
...(deps.resolveClaudeInheritedEnv
? { resolveInheritedEnv: deps.resolveClaudeInheritedEnv }
: {}),
resolveAuthPolicy: deps.resolveClaudeAuthPolicy,
...(deps.resolveClaudePermissionMode
? { resolvePermissionMode: deps.resolveClaudePermissionMode }
: {}),
...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}),
...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {}),
...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {})
}),
resolveLaunch: deps.resolveLaunch,
persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => {
const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence
const observedAt = Date.now()
@@ -227,7 +227,8 @@ export async function openStructuredCodexRpcHarness(
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/shell/home'
}),
resolveCodexOverrides: () => ({ CODEX_PROFILE: 'configured' }),
resolveAgentLaunchEnv: (agent): Record<string, string> =>
agent === 'codex' ? { CODEX_PROFILE: 'configured' } : {},
openCodexConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
@@ -0,0 +1,16 @@
import { sealStructuredSessionChild } from './structured-session-child-env'
export const SEALED_TEST_SPAWN_TOKEN = 'spawn-1'
/** The env the seal hands a session's child, with nothing inherited to strip. */
export function sealedChildEnv(
sessionId: string,
env: Record<string, string> = {}
): Record<string, string> {
return sealStructuredSessionChild({
sessionId,
spawnToken: SEALED_TEST_SPAWN_TOKEN,
env,
inheritedEnvToDelete: []
}).env
}
@@ -5,7 +5,10 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p
const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() }))
vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim)
import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env'
import {
SEALED_TEST_SPAWN_TOKEN,
sealedChildEnv
} from './structured-session-child-env.test-support'
import {
mintStructuredWorkerHandle,
mintStructuredWorkerPaneKey,
@@ -56,14 +59,14 @@ afterEach(() => {
}
})
describe('structuredSessionChildIdentityEnv', () => {
describe('sealStructuredSessionChild identity', () => {
it("gives an ordinary chat session its own id and this app's CLI, and no terminal identity", () => {
// The id names the caller, so a bare `orca orchestration check` acts as this session instead of
// guessing a terminal — every guess landed on a sibling pane, and `check` consumed its mail.
pinPlatform('linux')
installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA })
const childEnv = { PATH: '/usr/bin' }
const env = structuredSessionChildIdentityEnv(SESSION_ID, childEnv)
const env = sealedChildEnv(SESSION_ID, childEnv)
expect(env).toEqual({
PATH: `${SHIM_DIR}:/usr/bin`,
ORCA_AGENT_SESSION_ID: SESSION_ID,
@@ -72,7 +75,8 @@ describe('structuredSessionChildIdentityEnv', () => {
ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'),
ORCA_CLI_BIN_DIR: SHIM_DIR,
// The instance that minted the id, so any current CLI dials it rather than the default.
ORCA_USER_DATA_PATH: USER_DATA
ORCA_USER_DATA_PATH: USER_DATA,
ORCA_AGENT_SESSION_SPAWN_TOKEN: SEALED_TEST_SPAWN_TOKEN
})
// A chat names itself by its id alone: no handle, no pane key.
expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined()
@@ -82,7 +86,7 @@ describe('structuredSessionChildIdentityEnv', () => {
it('replaces an id inherited from an Orca launched inside another session', () => {
installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA })
const env = structuredSessionChildIdentityEnv(SESSION_ID, {
const env = sealedChildEnv(SESSION_ID, {
ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd',
PATH: '/usr/bin'
})
@@ -94,7 +98,7 @@ describe('structuredSessionChildIdentityEnv', () => {
// one identity; the handle stays for the handle-based surfaces outside orchestration.
installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA })
const handle = registerWorker()
const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' })
const env = sealedChildEnv(SESSION_ID, { PATH: '/usr/bin' })
expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID)
expect(env.ORCA_TERMINAL_HANDLE).toBe(handle)
})
@@ -107,7 +111,7 @@ describe('structuredSessionChildIdentityEnv', () => {
getPath: () => USER_DATA,
getCliLauncherPath: () => launcher
})
const env = structuredSessionChildIdentityEnv(SESSION_ID, {
const env = sealedChildEnv(SESSION_ID, {
PATH: '/usr/bin',
ORCA_USER_DATA_PATH: '/other/profile'
})
@@ -127,7 +131,7 @@ describe('structuredSessionChildIdentityEnv', () => {
// installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout.
pinPlatform('linux')
installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA })
const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' })
const env = sealedChildEnv(SESSION_ID, { PATH: '/usr/bin:/bin' })
expect(env.ORCA_CLI_COMMAND).toBe(join(SHIM_DIR, 'orca'))
expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`)
})
@@ -135,7 +139,7 @@ describe('structuredSessionChildIdentityEnv', () => {
it('on packaged macOS, through the bundled CLI dir', () => {
pinPlatform('darwin')
installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA })
const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' })
const env = sealedChildEnv(SESSION_ID, { PATH: '/usr/bin' })
expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`)
expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca'))
})
@@ -143,7 +147,7 @@ describe('structuredSessionChildIdentityEnv', () => {
it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => {
pinPlatform('win32')
installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA })
const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' })
const env = sealedChildEnv(SESSION_ID, { Path: 'C:\\Windows' })
expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`)
expect(env.PATH).toBeUndefined()
// The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle.
@@ -154,7 +158,7 @@ describe('structuredSessionChildIdentityEnv', () => {
it('unpackaged, through the dev launcher dir', () => {
pinPlatform('darwin')
installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA })
const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' })
const env = sealedChildEnv(SESSION_ID, { PATH: '/usr/bin' })
expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`)
expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev'))
})
@@ -167,7 +171,7 @@ describe('structuredSessionChildIdentityEnv', () => {
installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA })
shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const env = structuredSessionChildIdentityEnv(SESSION_ID, {
const env = sealedChildEnv(SESSION_ID, {
PATH: '/usr/bin',
ORCA_CLI_COMMAND: '/Applications/Other Orca.app/Contents/Resources/bin/orca',
ORCA_USER_DATA_PATH: '/data/other-orca'
@@ -184,7 +188,7 @@ describe('structuredSessionChildIdentityEnv', () => {
pinPlatform('linux')
installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA })
registerWorker()
const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' })
const env = sealedChildEnv(SESSION_ID, { PATH: '/usr/bin' })
expect(env.ORCA_PANE_KEY).toBeUndefined()
expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([])
})
@@ -204,8 +208,6 @@ describe('structuredSessionChildIdentityEnv', () => {
pinPlatform('linux')
installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA })
registerWorker()
expect(
structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND
).not.toBe('orca-ide')
expect(sealedChildEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND).not.toBe('orca-ide')
})
})
@@ -1,6 +1,9 @@
/**
* The last env step every native-chat child passes (`sealStructuredSessionChild`): Orca's identity
* for it, its spawn token, and the inherited keys its agent says it must not keep.
*
* The orchestration identity — and the CLI reachability — a structured session's own child needs to
* speak for itself. Both providers' native launches (Claude, Codex) build their child env here.
* speak for itself.
*
* Every structured session carries `ORCA_AGENT_SESSION_ID`, the id Orca minted for it — never the
* provider's, which rotates on `/clear`. The CLI sends it as the caller, so a bare
@@ -45,15 +48,73 @@
*/
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env'
import {
ORCA_AGENT_SESSION_ID_ENV,
ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV
} from '../../shared/agent-session-caller-env'
import {
ORCA_SCRUB_SAFE_LAUNCH_ENV,
ORCA_SCRUB_SAFE_PANE_ENV
} from '../../shared/agent-hook-scrub-safe-env'
import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys'
import { resolveStructuredWorkerIdentityForSession } from './structured-worker-authority'
export function structuredSessionChildIdentityEnv(
/**
* A pane's identity in the inherited environment would let the agent's own Orca status hooks
* report for this session too; the structured session is its one status producer.
*/
export const STRUCTURED_CHILD_ENV_TO_DELETE: readonly string[] = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN',
ORCA_SCRUB_SAFE_PANE_ENV,
ORCA_SCRUB_SAFE_LAUNCH_ENV,
...AGENT_HOOK_RUNTIME_ENV_KEYS
]
export type SealedStructuredSessionChild = {
env: Record<string, string>
/** Removed from what the child inherits, after `env` is laid over it. */
envToDelete: string[]
}
/**
* `env` named as this session's child. `inheritedEnvToDelete` is required so every agent states
* what it strips, none included; a key the seal itself names the child by is never stripped (a
* registered worker keeps its handle). `cliRuntimeCommand`: a Node CLI whose own directory goes
* first on PATH, after the identity's, so it runs on the runtime beside it.
*/
export function sealStructuredSessionChild(input: {
sessionId: string
spawnToken: string
env: Record<string, string>
inheritedEnvToDelete: readonly string[]
cliRuntimeCommand?: string
}): SealedStructuredSessionChild {
const identity = childIdentity(input.sessionId, input.env)
const named = new Set([
ORCA_AGENT_SESSION_ID_ENV,
ORCA_STRUCTURED_SESSION_ENV,
ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV,
...(identity.workerHandle ? ['ORCA_TERMINAL_HANDLE'] : [])
])
const env = input.cliRuntimeCommand
? withCliRuntimeOnPath(input.cliRuntimeCommand, identity.env, { platform: process.platform })
: identity.env
return {
env: { ...env, [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: input.spawnToken },
envToDelete: input.inheritedEnvToDelete.filter((key) => !named.has(key))
}
}
function childIdentity(
sessionId: string,
childEnv: Record<string, string>
): Record<string, string> {
): { env: Record<string, string>; workerHandle: string | null } {
const identity = resolveStructuredWorkerIdentityForSession(sessionId, null)
const env: Record<string, string> = {
...childEnv,
@@ -62,7 +123,7 @@ export function structuredSessionChildIdentityEnv(
[ORCA_STRUCTURED_SESSION_ENV]: '1'
}
applyThisAppCli(env)
return env
return { env, workerHandle: identity?.handle ?? null }
}
/**
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { runProcess } from '@orca/process-host'
import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env'
import { sealedChildEnv } from './structured-session-child-env.test-support'
const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666'
@@ -40,7 +40,7 @@ export function createLoginShellHarness(): LoginShellHarness {
writeFileSync(join(home, '.zprofile'), prependGlobal)
writeFileSync(join(home, '.bash_profile'), prependGlobal)
installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData })
const env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' })
const env = sealedChildEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' })
return {
run: async (spec) => (await runProcess({ ...spec, env })).stdout,
dispose: () => rmSync(root, { recursive: true, force: true })
@@ -36,8 +36,7 @@ const { listAddressableStructuredWorkers } =
await import('./orchestration/structured-worker-group-addressing')
const { readStructuredLineageJournalPage, STRUCTURED_JOURNAL_PAGE_LIMIT } =
await import('./orchestration/structured-worker-journal-page')
const { structuredSessionChildIdentityEnv } =
await import('./structured-session-child-identity-env')
const { sealedChildEnv } = await import('./structured-session-child-env.test-support')
const { foundAgentSessionRecord } = await import('./agent-session-record-founding')
const { applyAgentSessionRestartAdjudication } =
await import('./agent-session-restart-lease-transitions')
@@ -489,9 +488,7 @@ describe.each(['live', 'at-rest'] as const)(
it("gives the successor's child the worker's handle", () => {
const identity = registerWorker()
expect(structuredSessionChildIdentityEnv(SUCCESSOR, {}).ORCA_TERMINAL_HANDLE).toBe(
identity.handle
)
expect(sealedChildEnv(SUCCESSOR).ORCA_TERMINAL_HANDLE).toBe(identity.handle)
})
it("records the user's takeover when they type into the successor", () => {
@@ -5,7 +5,7 @@ import {
structuredAgentSessionTabId
} from '../../shared/structured-agent-session-projection'
import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session'
import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env'
import { sealedChildEnv } from './structured-session-child-env.test-support'
import {
StructuredWorkerIdentityRegistry,
isStructuredWorkerHandle,
@@ -300,7 +300,7 @@ describe('structured workers stay outside the PTY-only fail-closed paths', () =>
hostScope: { kind: 'local', hostId: 'local' }
})
try {
const env = structuredSessionChildIdentityEnv(SESSION_ID, {})
const env = sealedChildEnv(SESSION_ID)
// Registered, so the worker's handle is present; without it the pane-key assertion would pass
// for the wrong reason.
expect(env.ORCA_TERMINAL_HANDLE).toBe(handle)
+4
View File
@@ -12,6 +12,10 @@ import { isStructuredWorkerHandle } from './structured-worker-handle'
export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID'
/** The spawn token a structured session's child echoes, so the owner probe can tell a live child
* of that reservation from a same-pid stranger. */
export const ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN'
export function readInjectedAgentSessionId(
env: Readonly<Record<string, string | undefined>> = process.env
): string | undefined {