mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Match the Claude structured launch to the terminal path's managed-account auth rules
The SDK path stripped ambient Anthropic auth unconditionally, let an explicit agentDefaultEnv override beat a pinned managed account, and had no account-switch guard. Reuse the terminal preflight's own predicate and messages so both transports strip, refuse, and report identically, and cover the CLI transcript location that mobile native chat depends on.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import type { ClaudeManagedAccount } from '../../shared/managed-account-types'
|
||||
|
||||
export const CLAUDE_AUTH_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
@@ -41,6 +43,33 @@ export function applyClaudeEnvPatch(
|
||||
return baseEnv
|
||||
}
|
||||
|
||||
/** One string for every transport, so a terminal launch and a structured launch
|
||||
* cannot drift into telling the user two different things about one refusal. */
|
||||
export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE =
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
|
||||
export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE =
|
||||
'A Claude account switch is in progress. Try again after it finishes.'
|
||||
|
||||
/**
|
||||
* Whether a launch on the host runtime must drop inherited Anthropic auth.
|
||||
*
|
||||
* Only a pinned host-managed account owns the credential, so only it may strip:
|
||||
* with no managed account the user's own `ANTHROPIC_*` is their sign-in, and
|
||||
* removing it signs them out of a CLI that would otherwise have worked.
|
||||
*/
|
||||
export function shouldStripClaudeAuthEnvForAccount(
|
||||
accounts: readonly ClaudeManagedAccount[] | undefined,
|
||||
activeAccountId: string | null | undefined
|
||||
): boolean {
|
||||
if (!activeAccountId) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
(accounts ?? []).find((account) => account.id === activeAccountId)?.managedAuthRuntime !== 'wsl'
|
||||
)
|
||||
}
|
||||
|
||||
export function hasClaudeAuthEnvConflict(env: Record<string, string> | undefined): boolean {
|
||||
if (!env) {
|
||||
return false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { join } from 'node:path'
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { shouldStripClaudeAuthEnvForAccount } from '../environment'
|
||||
import { getDefaultWslDistro, getWslHome } from '../../wsl'
|
||||
import {
|
||||
getSelectedClaudeAccountIdForTarget,
|
||||
@@ -69,7 +70,10 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh
|
||||
wslDistro: null,
|
||||
wslLinuxConfigDir: null,
|
||||
envPatch: paths.envPatch,
|
||||
stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'),
|
||||
stripAuthEnv: shouldStripClaudeAuthEnvForAccount(
|
||||
settings.claudeManagedAccounts,
|
||||
activeAccountId
|
||||
),
|
||||
managedRefreshDeferredByLivePty: Boolean(
|
||||
activeAccountId &&
|
||||
activeAccount?.managedAuthRuntime !== 'wsl' &&
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
closeClaudeSession,
|
||||
claudeAcquisitionCleanupError
|
||||
} from './claude-structured-session-close'
|
||||
import type {
|
||||
ClaudeAcquisitionRegistry,
|
||||
ClaudeSession,
|
||||
ClaudeSessionExit,
|
||||
ClaudeStructuredSessionAdapterDeps
|
||||
} from './claude-structured-session-state'
|
||||
|
||||
/**
|
||||
* Cleanup for an acquisition the host could not commit or prove. A session that
|
||||
* a first-hand exit already removed is not an absence to report as proven: the
|
||||
* ladder on its connection still answers, and that answer is classified exactly
|
||||
* as a start-time failure would be.
|
||||
*/
|
||||
export async function releaseClaudeAcquisition(input: {
|
||||
sessionId: string
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
|
||||
onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent']
|
||||
}): Promise<boolean> {
|
||||
const exit = input.exits.get(input.sessionId)
|
||||
if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) {
|
||||
return closeClaudeSession(input)
|
||||
}
|
||||
const firstProof = exit.closePromise ? await exit.closePromise : false
|
||||
// A failed exit-path proof is retained as evidence, not as a terminal result;
|
||||
// a release retry must drive a fresh tree verification on the same connection.
|
||||
const retriedProof = firstProof || (await exit.connection.close())
|
||||
if (retriedProof) {
|
||||
await input.onExitProven?.(input.sessionId, exit)
|
||||
// Keep the first-hand exit evidence indexed until the tree proof succeeds;
|
||||
// a failed close must be retryable and cannot look like an absent session.
|
||||
input.exits.delete(input.sessionId)
|
||||
return true
|
||||
}
|
||||
throw claudeAcquisitionCleanupError(exit.connection, exit.error)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE
|
||||
} from '../claude-accounts/environment'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
|
||||
import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support'
|
||||
|
||||
const SESSION_ID = 'orca-session-auth'
|
||||
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
|
||||
ReturnType<typeof createClaudeStructuredLaunchResolver>
|
||||
>[0]['identity']
|
||||
|
||||
function record(): AgentSessionRecord {
|
||||
return {
|
||||
sessionId: SESSION_ID,
|
||||
provider: 'claude',
|
||||
location: {
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
|
||||
providerHandleChain: []
|
||||
} as unknown as AgentSessionRecord
|
||||
}
|
||||
|
||||
function resolverFor(options: {
|
||||
stripAuthEnv?: boolean
|
||||
overlay?: Record<string, string>
|
||||
}): ReturnType<typeof createClaudeStructuredLaunchResolver> {
|
||||
return createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
...(options.stripAuthEnv === undefined
|
||||
? {}
|
||||
: { resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv === true }) }),
|
||||
...(options.overlay ? { resolveEnv: () => options.overlay as Record<string, string> } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function withAmbientAuth<T>(value: string, run: () => Promise<T>): Promise<T> {
|
||||
const restore = process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = value
|
||||
return run().finally(() => {
|
||||
if (restore === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = restore
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('claude structured auth parity with the terminal preflight', () => {
|
||||
afterEach(() => {
|
||||
endClaudeAuthSwitch()
|
||||
})
|
||||
|
||||
// Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and
|
||||
// runtime/spawn-preflight.ts:139; the structured path used to let the override win.
|
||||
it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => {
|
||||
await expect(
|
||||
resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({
|
||||
identity: IDENTITY
|
||||
})
|
||||
).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
})
|
||||
|
||||
it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => {
|
||||
await expect(
|
||||
resolverFor({
|
||||
stripAuthEnv: true,
|
||||
overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' }
|
||||
})({ identity: IDENTITY })
|
||||
).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
})
|
||||
|
||||
it('still admits a non-auth env overlay under a managed account', async () => {
|
||||
const launch = await resolverFor({
|
||||
stripAuthEnv: true,
|
||||
overlay: { ANTHROPIC_BASE_URL: 'https://gateway.example.test' }
|
||||
})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test')
|
||||
})
|
||||
|
||||
// Task 2 — legacy computes stripAuthEnv at runtime-auth-preparation.ts:72, so a
|
||||
// system-auth user's own shell key is their sign-in and must survive.
|
||||
it('passes an ambient Anthropic key through when no managed account is active', async () => {
|
||||
await withAmbientAuth('sk-ant-SHELL', async () => {
|
||||
const launch = await resolverFor({ stripAuthEnv: false })({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL')
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an explicit overlay override the ambient key when no managed account is active', async () => {
|
||||
await withAmbientAuth('sk-ant-SHELL', async () => {
|
||||
const launch = await resolverFor({
|
||||
stripAuthEnv: false,
|
||||
overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' }
|
||||
})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED')
|
||||
})
|
||||
})
|
||||
|
||||
it('still strips the ambient Anthropic key when a managed account is pinned', async () => {
|
||||
await withAmbientAuth('sk-ant-SHELL', async () => {
|
||||
const launch = await resolverFor({ stripAuthEnv: true })({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Task 3 — the terminal preflight guards this at four sites; the structured path had none.
|
||||
it('refuses launch resolution while a Claude account switch is in progress', async () => {
|
||||
beginClaudeAuthSwitch()
|
||||
|
||||
await expect(resolverFor({ stripAuthEnv: true })({ identity: IDENTITY })).rejects.toThrow(
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses an acquire before it tears the previous session down', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = adapterFor(claude)
|
||||
beginClaudeAuthSwitch()
|
||||
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
// Nothing was spawned, so the refusal must not have opened a connection.
|
||||
expect(claude.connections).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -49,11 +49,16 @@ function makeExecutable(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function resolverFor(value: AgentSessionRecord | null, resolveEnv?: () => Record<string, string>) {
|
||||
function resolverFor(
|
||||
value: AgentSessionRecord | null,
|
||||
resolveEnv?: () => Record<string, string>,
|
||||
stripAuthEnv = false
|
||||
) {
|
||||
return createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv }),
|
||||
...(resolveEnv ? { resolveEnv } : {})
|
||||
})
|
||||
}
|
||||
@@ -200,7 +205,10 @@ describe('claude structured launch resolution', () => {
|
||||
expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token')
|
||||
})
|
||||
|
||||
it('strips ambient Anthropic auth from the inherited env but keeps the rest of it', async () => {
|
||||
// Stripping is the managed-account rule the terminal preflight computes at
|
||||
// runtime-auth-preparation.ts:72; claude-structured-auth-parity.test.ts covers
|
||||
// the system-auth half, where the user's own key has to survive.
|
||||
it('strips ambient Anthropic auth under a managed account but keeps the rest of the env', async () => {
|
||||
const restore = {
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
@@ -212,7 +220,7 @@ describe('claude structured launch resolution', () => {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK'
|
||||
process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited'
|
||||
try {
|
||||
const launch = await resolverFor(record())({ identity: IDENTITY })
|
||||
const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
@@ -231,7 +239,7 @@ describe('claude structured launch resolution', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('lets an explicit Claude env overlay override the stripped ambient auth', async () => {
|
||||
it('lets an explicit Claude env overlay override ambient auth under system auth', async () => {
|
||||
const restore = process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK'
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,13 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou
|
||||
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
|
||||
applyClaudeEnvPatch,
|
||||
hasClaudeAuthEnvConflict
|
||||
} from '../claude-accounts/environment'
|
||||
import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate'
|
||||
import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
|
||||
@@ -110,6 +116,16 @@ export type ClaudeStructuredLaunch = {
|
||||
resumed: boolean
|
||||
}
|
||||
|
||||
/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result:
|
||||
* the one field a launch resolution needs from the managed-account state. */
|
||||
export type ClaudeStructuredAuthPolicy = {
|
||||
stripAuthEnv: boolean
|
||||
}
|
||||
|
||||
/** No policy resolver wired means no managed-account service to answer for, which
|
||||
* is what the terminal preflight computes when no account is selected. */
|
||||
const SYSTEM_AUTH_POLICY: ClaudeStructuredAuthPolicy = { stripAuthEnv: false }
|
||||
|
||||
export type ClaudeStructuredLaunchResolverDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
resolveWorkspacePath: (workspaceId: string) => Promise<string>
|
||||
@@ -118,6 +134,13 @@ export type ClaudeStructuredLaunchResolverDeps = {
|
||||
| Promise<Record<string, string> | undefined>
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
resolveAuthPolicy?: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
||||
}
|
||||
|
||||
export function assertClaudeAuthSwitchIdle(): void {
|
||||
if (isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeSessionIdForOrcaSession(sessionId: string): string {
|
||||
@@ -132,6 +155,7 @@ export function createClaudeStructuredLaunchResolver(
|
||||
deps: ClaudeStructuredLaunchResolverDeps
|
||||
): (input: { identity: AgentSessionJournalIdentity }) => Promise<ClaudeStructuredLaunch> {
|
||||
return async ({ identity }) => {
|
||||
assertClaudeAuthSwitchIdle()
|
||||
const record = deps.store.getRecord(identity.sessionId)
|
||||
if (!record) {
|
||||
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
|
||||
@@ -165,11 +189,21 @@ export function createClaudeStructuredLaunchResolver(
|
||||
: claudeSessionIdForOrcaSession(identity.sessionId)
|
||||
const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? [])
|
||||
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
|
||||
const auth = (await deps.resolveAuthPolicy?.()) ?? SYSTEM_AUTH_POLICY
|
||||
const overlay = await deps.resolveEnv?.()
|
||||
// A switch can begin while the policy and overlay resolve, exactly as it can
|
||||
// during the terminal preflight's prepareClaudeAuth — recheck after the awaits.
|
||||
assertClaudeAuthSwitchIdle()
|
||||
// Under a managed account the pinned credential is the only auth this launch may
|
||||
// use, so an explicit override is refused rather than silently beating the pin.
|
||||
if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) {
|
||||
throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
}
|
||||
// Why the overlay merges onto the inherited env rather than replacing it: the child
|
||||
// still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath
|
||||
// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the
|
||||
// inherited half only, so an explicit agentDefaultEnv override still wins.
|
||||
// inherited half only when a managed account owns the credential; a system-auth
|
||||
// user's own key is their sign-in and must reach the child.
|
||||
const env = withCliRuntimeOnPath(
|
||||
command,
|
||||
{
|
||||
@@ -177,7 +211,7 @@ export function createClaudeStructuredLaunchResolver(
|
||||
cloneDefinedEnv(process.env),
|
||||
{},
|
||||
{
|
||||
stripAuthEnv: true,
|
||||
stripAuthEnv: auth.stripAuthEnv,
|
||||
platform: process.platform
|
||||
}
|
||||
),
|
||||
|
||||
@@ -2,10 +2,11 @@ import { spawnSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { basename, join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
|
||||
import {
|
||||
@@ -66,6 +67,24 @@ function identity(providerSessionId: string): AgentSessionJournalIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
/** The CLI flushes its transcript on its own schedule; poll rather than race it. */
|
||||
async function waitForResolvedTranscript(
|
||||
providerSessionId: string,
|
||||
claudeProjectsDir: string,
|
||||
timeoutMs = 15_000
|
||||
): Promise<string | null> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
const resolved = await resolveSessionFilePath('claude', providerSessionId, {
|
||||
claudeProjectsDir
|
||||
})
|
||||
if (resolved || Date.now() >= deadline) {
|
||||
return resolved
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => {
|
||||
it.skipIf(!realClaudeAuthenticated)(
|
||||
'proves a pre-minted session before the first user message',
|
||||
@@ -100,6 +119,46 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', ()
|
||||
10_000
|
||||
)
|
||||
|
||||
// Mobile native chat never reads the structured journal — it reads the CLI's own
|
||||
// transcript through native-chat/session-file-resolver.ts, which is also how
|
||||
// structured-claude-runtime-adapter.ts recovers a leaf. So the SDK-spawned CLI has
|
||||
// to keep writing that transcript under the pinned account home; if it ever moved,
|
||||
// mobile chat and TUI resume would both go dark with no wire-level error.
|
||||
// The turn is what creates the file: an init-only handshake writes nothing.
|
||||
it.skipIf(!realClaudeAuthenticated)(
|
||||
'writes its transcript where the mobile session-file resolver looks for it',
|
||||
async () => {
|
||||
const providerSessionId = randomUUID()
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
|
||||
const adapter = realAdapter(providerSessionId, claudeConfigDir)
|
||||
const projectsDir = join(claudeConfigDir, 'projects')
|
||||
|
||||
let transcriptPath: string | null = null
|
||||
try {
|
||||
await adapter.acquire({
|
||||
identity: identity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'real-cli-transcript'
|
||||
})
|
||||
await adapter.dispatch({
|
||||
sessionId: 'real-cli-handshake',
|
||||
clientMessageId: 'real-cli-transcript-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
|
||||
fence: 1
|
||||
})
|
||||
transcriptPath = await waitForResolvedTranscript(providerSessionId, projectsDir)
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
|
||||
expect(transcriptPath).not.toBeNull()
|
||||
expect(basename(transcriptPath ?? '')).toBe(`${providerSessionId}.jsonl`)
|
||||
// `<pinned account home>/projects/<project slug>/<provider session id>.jsonl`
|
||||
expect(relative(projectsDir, transcriptPath ?? '').split(/[\\/]/)).toHaveLength(2)
|
||||
},
|
||||
45_000
|
||||
)
|
||||
|
||||
it('turns a real silent unauthenticated startup into sign-in guidance', async () => {
|
||||
const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-'))
|
||||
const providerSessionId = randomUUID()
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
AgentSessionAcquisition,
|
||||
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 { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment'
|
||||
import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate'
|
||||
import { openClaudeStreamJsonConnection } from './claude-stream-json-connection'
|
||||
import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control'
|
||||
import { resolveClaudeReplayWaiter } from './claude-structured-dispatch'
|
||||
@@ -34,32 +35,19 @@ import {
|
||||
cancelClaudeAcquisitionAttempt,
|
||||
mintClaudeAcquisitionGeneration,
|
||||
type ClaudeAcquisitionRegistry,
|
||||
type ClaudeAcquisitionAttempt,
|
||||
type ClaudeSession,
|
||||
type ClaudeSessionExit,
|
||||
type ClaudeStructuredSessionAdapterDeps,
|
||||
type ClaudeStructuredSessionEvent
|
||||
type ClaudeAcquireCallbacks
|
||||
} from './claude-structured-session-state'
|
||||
import {
|
||||
closeClaudePublishedSessionForDeps,
|
||||
closeClaudeSession,
|
||||
claudeAcquisitionCleanupError
|
||||
} from './claude-structured-session-close'
|
||||
import { readClaudeTranscriptEntryUuid } from './claude-tui-exit'
|
||||
|
||||
export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000
|
||||
|
||||
type AcquireCallbacks = {
|
||||
deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void
|
||||
emit: (
|
||||
session: ClaudeSession | null,
|
||||
events: StructuredAgentSessionEventSink | undefined,
|
||||
event: ClaudeStructuredSessionEvent
|
||||
) => void
|
||||
handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void
|
||||
settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
}
|
||||
|
||||
export async function acquireClaudeSession({
|
||||
input,
|
||||
deps,
|
||||
@@ -73,8 +61,13 @@ export async function acquireClaudeSession({
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
callbacks: AcquireCallbacks
|
||||
callbacks: ClaudeAcquireCallbacks
|
||||
}): Promise<AgentSessionAcquisition> {
|
||||
// A managed-account switch is mid-swap of the pinned credential home; refuse here,
|
||||
// before this acquisition cancels the previous attempt and closes the live session.
|
||||
if (isClaudeAuthSwitchInProgress()) {
|
||||
throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE))
|
||||
}
|
||||
const sessionId = input.identity.sessionId
|
||||
const prompts = new ClaudePromptRegistry()
|
||||
const translator = createClaudeSessionJournalTranslator(
|
||||
@@ -294,36 +287,3 @@ export async function acquireClaudeSession({
|
||||
attempt.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup for an acquisition the host could not commit or prove. A session that
|
||||
* a first-hand exit already removed is not an absence to report as proven: the
|
||||
* ladder on its connection still answers, and that answer is classified exactly
|
||||
* as a start-time failure would be.
|
||||
*/
|
||||
export async function releaseClaudeAcquisition(input: {
|
||||
sessionId: string
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
|
||||
onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent']
|
||||
}): Promise<boolean> {
|
||||
const exit = input.exits.get(input.sessionId)
|
||||
if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) {
|
||||
return closeClaudeSession(input)
|
||||
}
|
||||
const firstProof = exit.closePromise ? await exit.closePromise : false
|
||||
// A failed exit-path proof is retained as evidence, not as a terminal result;
|
||||
// a release retry must drive a fresh tree verification on the same connection.
|
||||
const retriedProof = firstProof || (await exit.connection.close())
|
||||
if (retriedProof) {
|
||||
await input.onExitProven?.(input.sessionId, exit)
|
||||
// Keep the first-hand exit evidence indexed until the tree proof succeeds;
|
||||
// a failed close must be retryable and cannot look like an absent session.
|
||||
input.exits.delete(input.sessionId)
|
||||
return true
|
||||
}
|
||||
throw claudeAcquisitionCleanupError(exit.connection, exit.error)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@ import type {
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions'
|
||||
import { dispatchClaudeTurn } from './claude-structured-dispatch'
|
||||
import {
|
||||
acquireClaudeSession,
|
||||
releaseClaudeAcquisition
|
||||
} from './claude-structured-session-acquisition'
|
||||
import { releaseClaudeAcquisition } from './claude-structured-acquisition-release'
|
||||
import { acquireClaudeSession } from './claude-structured-session-acquisition'
|
||||
export { CLAUDE_STRUCTURED_INIT_TIMEOUT_MS } from './claude-structured-session-acquisition'
|
||||
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
|
||||
import { setClaudeStructuredOption } from './claude-structured-options'
|
||||
|
||||
@@ -254,3 +254,16 @@ export async function cancelClaudeAcquisitionAttempt(
|
||||
finished: attempt.finished
|
||||
})
|
||||
}
|
||||
|
||||
/** What an acquisition hands back to the adapter that owns the session map:
|
||||
* event delivery ordered against publication, and the two exit settlements. */
|
||||
export type ClaudeAcquireCallbacks = {
|
||||
deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void
|
||||
emit: (
|
||||
session: ClaudeSession | null,
|
||||
events: StructuredAgentSessionEventSink | undefined,
|
||||
event: ClaudeStructuredSessionEvent
|
||||
) => void
|
||||
handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void
|
||||
settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay'
|
||||
import { isOpaqueRemintedPaneKey } from '../../../../shared/pane-key-alias'
|
||||
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
|
||||
import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate'
|
||||
import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
|
||||
hasClaudeAuthEnvConflict
|
||||
} from '../../../claude-accounts/environment'
|
||||
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
|
||||
import { resolvePathEnvKey } from '../../../pty/windows-environment-path'
|
||||
import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing'
|
||||
@@ -20,12 +24,10 @@ import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex'
|
||||
export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
const args = ctx.args
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
}
|
||||
// Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity.
|
||||
// Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '../../../../shared/local-windows-terminal-runtime'
|
||||
import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths'
|
||||
import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate'
|
||||
import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment'
|
||||
import { mintPtySessionId } from '../../../daemon/pty-session-id'
|
||||
import { resolveWslSessionContext } from '../../../daemon/wsl-session-context'
|
||||
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
|
||||
@@ -193,7 +194,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
ctx.isClaudeLaunch =
|
||||
!ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
ctx.terminalRuntimeOptions =
|
||||
process.platform === 'win32' && !args.connectionId
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate'
|
||||
import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
|
||||
hasClaudeAuthEnvConflict
|
||||
} from '../../../claude-accounts/environment'
|
||||
import {
|
||||
isSafePtySessionId,
|
||||
mintPtySessionId,
|
||||
@@ -65,7 +69,7 @@ export async function prepareRuntimePtySpawn(
|
||||
ctx.isClaudeLaunch =
|
||||
!ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
// Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime.
|
||||
ctx.terminalRuntimeOptions =
|
||||
@@ -134,12 +138,10 @@ export async function prepareRuntimePtySpawn(
|
||||
? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget)
|
||||
: null
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
}
|
||||
|
||||
ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { RpcRequest, RpcResponse } from './rpc/core'
|
||||
import type { ClaudeStructuredAuthPolicy } from '../claude/claude-structured-launch-resolution'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session'
|
||||
import {
|
||||
@@ -240,6 +241,9 @@ let dispatcher: RpcDispatcher
|
||||
let cleanups: Map<string, () => void>
|
||||
let tuiOwner: StructuredTuiOwner | null
|
||||
let transcriptPath: string
|
||||
/** Managed-account state and configured overlay this host installs, per test. */
|
||||
let claudeAuthPolicy: ClaudeStructuredAuthPolicy
|
||||
let claudeLaunchEnv: Record<string, string>
|
||||
|
||||
async function call(method: string, params: unknown): Promise<RpcResponse> {
|
||||
const replies: RpcResponse[] = []
|
||||
@@ -303,6 +307,11 @@ function textOf(item: AgentJournalRenderItem): string {
|
||||
|
||||
beforeEach(async () => {
|
||||
operations = 0
|
||||
claudeAuthPolicy = { stripAuthEnv: false }
|
||||
claudeLaunchEnv = {
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
|
||||
}
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-'))
|
||||
transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`)
|
||||
await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true })
|
||||
@@ -390,10 +399,8 @@ beforeEach(async () => {
|
||||
resolveCodexCommand: () => '/usr/local/bin/codex',
|
||||
resolveClaudeCommand: () => '/usr/local/bin/claude',
|
||||
readProcessStartTime: async (pid: number) => pid * 10,
|
||||
resolveClaudeLaunchEnv: () => ({
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
|
||||
}),
|
||||
resolveClaudeLaunchEnv: () => claudeLaunchEnv,
|
||||
resolveClaudeAuthPolicy: () => claudeAuthPolicy,
|
||||
openClaudeConnection: claude.openConnection,
|
||||
handoffTransport
|
||||
}).then(() => undefined),
|
||||
@@ -414,6 +421,34 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('a structured Claude session over agentSession.*', () => {
|
||||
it('strips ambient Anthropic auth from the child once a managed account is pinned', async () => {
|
||||
claudeAuthPolicy = { stripAuthEnv: true }
|
||||
claudeLaunchEnv = { ANTHROPIC_BASE_URL: 'https://gateway.example.test' }
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK')
|
||||
vi.stubEnv('ANTHROPIC_AUTH_TOKEN', 'tok-SHELL-LEAK')
|
||||
|
||||
await ok<{ fence: number }>('agentSession.create', createIntentParams())
|
||||
|
||||
const env = claude.live().launch.env
|
||||
expect(env).not.toHaveProperty('ANTHROPIC_API_KEY')
|
||||
expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN')
|
||||
expect(env).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
|
||||
CLAUDE_CONFIG_DIR: join(root, 'claude-home')
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a create whose configured env overrides the pinned managed account auth', async () => {
|
||||
claudeAuthPolicy = { stripAuthEnv: true }
|
||||
// The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path
|
||||
// refuses at spawn-env.ts:25 rather than letting it beat the pinned account.
|
||||
const refused = await call('agentSession.create', createIntentParams())
|
||||
|
||||
expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment')
|
||||
// Refused before spawn: no provider child was ever opened.
|
||||
expect(claude.connections).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('durably returns actionable sign-in guidance when initialization has no credentials', async () => {
|
||||
claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' })
|
||||
const params = createIntentParams()
|
||||
@@ -503,8 +538,9 @@ describe('a structured Claude session over agentSession.*', () => {
|
||||
CLAUDE_CONFIG_DIR: join(root, 'claude-home'),
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String)
|
||||
})
|
||||
// The child inherits the shell env for PATH, but never the ambient Anthropic auth.
|
||||
expect(claude.live().launch.env).not.toHaveProperty('ANTHROPIC_API_KEY')
|
||||
// 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.
|
||||
expect(claude.live().launch.env).toMatchObject({ ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' })
|
||||
expect(claude.live().launch.env?.PATH ?? claude.live().launch.env?.Path).toBeTruthy()
|
||||
const history = await call('agentSession.history', {
|
||||
sessionId: SESSION,
|
||||
|
||||
@@ -25,6 +25,8 @@ import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-ag
|
||||
import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args'
|
||||
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import { hostname } from 'node:os'
|
||||
import { shouldStripClaudeAuthEnvForAccount } from '../claude-accounts/environment'
|
||||
import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection'
|
||||
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
|
||||
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
|
||||
|
||||
@@ -156,6 +158,17 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
|
||||
resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv),
|
||||
resolveClaudeLaunchEnv: () =>
|
||||
resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv),
|
||||
// Structured Claude always spawns a native local-host child (the launch resolver
|
||||
// refuses any WSL or remote record), so the host selection owns its auth.
|
||||
resolveClaudeAuthPolicy: () => {
|
||||
const settings = this.requireStore().getSettings()
|
||||
return {
|
||||
stripAuthEnv: shouldStripClaudeAuthEnvForAccount(
|
||||
settings.claudeManagedAccounts,
|
||||
getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })
|
||||
)
|
||||
}
|
||||
},
|
||||
handoffTransport: this.createStructuredAgentSessionHandoffTransport()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-re
|
||||
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
|
||||
import { resolveLoginShellEnvironment } from '../startup/login-shell-environment'
|
||||
import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition'
|
||||
import type { ClaudeStructuredAuthPolicy } from '../claude/claude-structured-launch-resolution'
|
||||
import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter'
|
||||
|
||||
/** Sibling of the journal tree rather than inside it: one file adjudicates every
|
||||
@@ -68,6 +69,7 @@ export type StructuredAgentSessionRuntimeDeps = {
|
||||
resolveLaunchEnv?: () => Promise<NodeJS.ProcessEnv>
|
||||
resolveLaunchEnvOverlay?: () => Promise<Record<string, string>> | Record<string, string>
|
||||
resolveClaudeLaunchEnv?: () => Promise<Record<string, string>> | Record<string, string>
|
||||
resolveClaudeAuthPolicy?: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
||||
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
|
||||
resolveCodexOverrides?: () => NodeJS.ProcessEnv
|
||||
onError?: (input: { scope: string; error: unknown }) => void
|
||||
@@ -185,6 +187,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
|
||||
...(deps.resolveClaudeLaunchEnv
|
||||
? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv }
|
||||
: {}),
|
||||
...(deps.resolveClaudeAuthPolicy
|
||||
? { resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy }
|
||||
: {}),
|
||||
onUnexpectedExit: (event) => {
|
||||
recoveryChain = recoveryChain.then(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { join } from 'node:path'
|
||||
import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution'
|
||||
import {
|
||||
createClaudeStructuredLaunchResolver,
|
||||
type ClaudeStructuredAuthPolicy
|
||||
} from '../claude/claude-structured-launch-resolution'
|
||||
import {
|
||||
ClaudeStructuredSessionAdapter,
|
||||
type ClaudeStructuredSessionAdapterDeps
|
||||
@@ -20,6 +23,8 @@ export type StructuredClaudeRuntimeAdapterDeps = {
|
||||
resolveWorkspacePath: (workspaceId: string) => Promise<string>
|
||||
resolveClaudeCommand?: () => string
|
||||
resolveClaudeLaunchEnv?: () => Promise<Record<string, string>> | Record<string, string>
|
||||
/** Managed-account auth state for a Claude launch, mirroring the terminal preflight. */
|
||||
resolveClaudeAuthPolicy?: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
||||
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
|
||||
readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime']
|
||||
onUnexpectedExit: (event: StructuredAgentSessionLifecycleEvent) => void
|
||||
@@ -34,7 +39,8 @@ export function createStructuredClaudeRuntimeAdapter(
|
||||
store,
|
||||
resolveWorkspacePath: deps.resolveWorkspacePath,
|
||||
resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand,
|
||||
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
|
||||
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}),
|
||||
...(deps.resolveClaudeAuthPolicy ? { resolveAuthPolicy: deps.resolveClaudeAuthPolicy } : {})
|
||||
}),
|
||||
persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => {
|
||||
const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence
|
||||
|
||||
Reference in New Issue
Block a user