fix: prove real Claude structured startup

This commit is contained in:
Brennan Benson
2026-08-10 05:22:25 -07:00
parent 6e2a3dbf92
commit 2729ec02f3
13 changed files with 341 additions and 32 deletions
@@ -1,4 +1,7 @@
import type { ClaudeInitObservation } from './claude-structured-init-proof'
import { claudeInitializationAuthError } from './claude-structured-init-proof'
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
export type ClaudeInitDeadline = {
promise: Promise<ClaudeInitObservation>
@@ -8,6 +11,39 @@ export type ClaudeInitDeadline = {
clear: () => void
}
export function claudeInitTimeoutError(
sessionId: string,
timeoutMs: number
): AgentSessionAcquisitionRefusal {
return new AgentSessionAcquisitionRefusal(
`Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.`
)
}
export async function requestClaudeInitialization(
connection: ClaudeStreamJsonConnection,
sessionId: string,
timeoutMs: number
): Promise<unknown> {
try {
const result = await connection.request(
'initialize',
{ supportedDialogKinds: [] },
{ timeoutMs }
)
const authError = claudeInitializationAuthError(result)
if (authError) {
throw authError
}
return result
} catch (error) {
if (error instanceof Error && error.message === 'claude initialize request timed out') {
throw claudeInitTimeoutError(sessionId, timeoutMs)
}
throw error
}
}
export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline {
let resolve = (_init: ClaudeInitObservation): void => {}
let reject = (_error: Error): void => {}
@@ -23,10 +59,7 @@ export function createClaudeInitDeadline(sessionId: string, timeoutMs: number):
resolve,
reject,
start: () => {
timer = setTimeout(
() => reject(new Error(`claude session ${sessionId} did not emit system/init`)),
timeoutMs
)
timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs)
timer.unref?.()
},
clear: () => {
@@ -1,5 +1,6 @@
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
import type { ClaudeAuthDiagnostic } from './claude-structured-session-state'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
export type ClaudeInitObservation = {
providerSessionId: string
@@ -17,14 +18,20 @@ export function readClaudeFrameString(source: Record<string, unknown>, key: stri
}
export function readClaudeInit(message: Record<string, unknown>): ClaudeInitObservation | null {
if (message.type !== 'system' || message.subtype !== 'init') {
const hookName = readClaudeFrameString(message, 'hook_name')
const isInit = message.type === 'system' && message.subtype === 'init'
const isSessionStart =
message.type === 'system' &&
(message.subtype === 'hook_started' || message.subtype === 'hook_response') &&
hookName?.startsWith('SessionStart:') === true
if (!isInit && !isSessionStart) {
return null
}
const providerSessionId = readClaudeFrameString(message, 'session_id')
return providerSessionId
? {
providerSessionId,
uuid: readClaudeFrameString(message, 'uuid'),
uuid: isInit ? readClaudeFrameString(message, 'uuid') : null,
message
}
: null
@@ -36,6 +43,18 @@ export function readClaudeModels(initialization: unknown): unknown[] {
: []
}
export function claudeInitializationAuthError(
initialization: unknown
): AgentSessionAcquisitionRefusal | null {
const account =
isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null
return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none'
? new AgentSessionAcquisitionRefusal(
'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.'
)
: null
}
export function claudeAuthDiagnostic(
init: ClaudeInitObservation,
settings: unknown
@@ -4,6 +4,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import {
CLAUDE_DEFAULT_SETTING_SOURCES,
CLAUDE_STRUCTURED_BASE_ARGS,
claudeSessionIdForOrcaSession,
createClaudeStructuredLaunchResolver
} from './claude-structured-launch-resolution'
@@ -58,6 +59,7 @@ describe('claude structured launch resolution', () => {
expect(first.args).toContain('stdio')
expect(first.args).toContain('--setting-sources')
expect(first.args).toContain(CLAUDE_DEFAULT_SETTING_SOURCES.join(','))
expect(CLAUDE_STRUCTURED_BASE_ARGS).toContain('--verbose')
})
it('resumes the session and leaf at the durable chain head', async () => {
@@ -8,7 +8,7 @@ import { getSpawnArgsForWindows } from '../win32-utils'
export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const
const BASE_ARGS = [
export const CLAUDE_STRUCTURED_BASE_ARGS = [
'-p',
'--input-format',
'stream-json',
@@ -81,7 +81,10 @@ export function createClaudeStructuredLaunchResolver(
? ['--resume', providerSessionId]
: ['--session-id', providerSessionId]
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [...BASE_ARGS, ...providerArgs])
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [
...CLAUDE_STRUCTURED_BASE_ARGS,
...providerArgs
])
return {
command: spawnCmd,
args: spawnArgs,
@@ -0,0 +1,108 @@
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 { describe, expect, it } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { resolveClaudeCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import { CLAUDE_STRUCTURED_BASE_ARGS } from './claude-structured-launch-resolution'
import {
ClaudeStructuredSessionAdapter,
type ClaudeStructuredSessionEvent
} from './claude-structured-session-adapter'
const command = resolveClaudeCommand()
const versionLaunch = getSpawnArgsForWindows(command, ['--version'])
const realClaudeAvailable =
spawnSync(versionLaunch.spawnCmd, versionLaunch.spawnArgs, {
stdio: 'ignore',
windowsHide: true,
timeout: 5_000
}).status === 0
function realAdapter(
providerSessionId: string,
claudeConfigDir: string,
events: ClaudeStructuredSessionEvent[] = []
): ClaudeStructuredSessionAdapter {
const launch = getSpawnArgsForWindows(command, [
...CLAUDE_STRUCTURED_BASE_ARGS,
'--session-id',
providerSessionId
])
return new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
command: launch.spawnCmd,
args: launch.spawnArgs,
cwd: process.cwd(),
claudeConfigDir,
providerSessionId,
resumeLeafUuid: null,
resumed: false
}),
onEvent: (event) => events.push(event),
readProcessStartTime: async () => 1,
now: () => 2,
initTimeoutMs: 5_000
})
}
function identity(providerSessionId: string): AgentSessionJournalIdentity {
return {
sessionId: 'real-cli-handshake',
workspaceId: 'real-cli-workspace',
hostId: 'local',
agent: 'claude',
providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null }
}
}
describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => {
it('proves a pre-minted session before the first user message', async () => {
const providerSessionId = randomUUID()
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
const events: ClaudeStructuredSessionEvent[] = []
const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
try {
const acquisition = await adapter.acquire({
identity: identity(providerSessionId),
fence: 1,
spawnToken: 'real-cli'
})
const observedSubtypes = events.flatMap((event) =>
event.type === 'message' ? [event.message.subtype] : []
)
expect(acquisition.link.handle).toMatchObject({
provider: 'claude',
sessionId: providerSessionId,
leafUuid: expect.any(String)
})
expect(observedSubtypes).toContain('hook_started')
} finally {
await adapter.closeAll()
}
}, 10_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()
const adapter = realAdapter(providerSessionId, claudeConfigDir)
try {
await expect(
adapter.acquire({
identity: identity(providerSessionId),
fence: 1,
spawnToken: 'real-cli-no-auth'
})
).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s)
} finally {
await adapter.closeAll()
await rm(claudeConfigDir, { recursive: true, force: true })
}
}, 10_000)
})
@@ -3,6 +3,7 @@ import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
ClaudeStreamJsonConnection,
ClaudeStreamJsonConnectionHandlers,
@@ -13,6 +14,7 @@ import { ClaudeControlRequestError } from './claude-stream-json-connection'
import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity'
import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies'
import {
CLAUDE_STRUCTURED_INIT_TIMEOUT_MS,
ClaudeStructuredSessionAdapter,
type ClaudeStructuredLaunch,
type ClaudeStructuredSessionEvent
@@ -54,6 +56,8 @@ function fakeClaude(
initUuid?: string
initModel?: string
initEffort?: string
initProof?: 'init' | 'session-start' | 'none'
initAccount?: unknown
exitBeforeInit?: string
settings?: unknown
replayUuid?: string | null
@@ -83,16 +87,29 @@ function fakeClaude(
handlers.onExit?.(new Error(options.exitBeforeInit))
return { models: [] }
}
handlers.onMessage?.({
type: 'system',
subtype: 'init',
session_id: options.initSessionId ?? PROVIDER_SESSION_ID,
uuid: options.initUuid ?? 'init-uuid',
model: options.initModel ?? 'claude-sonnet-5',
effortLevel: options.initEffort ?? 'high',
apiKeySource: 'none'
})
return { models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }] }
if (options.initProof === 'session-start') {
handlers.onMessage?.({
type: 'system',
subtype: 'hook_started',
hook_name: 'SessionStart:startup',
session_id: options.initSessionId ?? PROVIDER_SESSION_ID,
uuid: options.initUuid ?? 'init-uuid'
})
} else if (options.initProof !== 'none') {
handlers.onMessage?.({
type: 'system',
subtype: 'init',
session_id: options.initSessionId ?? PROVIDER_SESSION_ID,
uuid: options.initUuid ?? 'init-uuid',
model: options.initModel ?? 'claude-sonnet-5',
effortLevel: options.initEffort ?? 'high',
apiKeySource: 'none'
})
}
return {
models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }],
...(options.initAccount === undefined ? {} : { account: options.initAccount })
}
}
if (subtype === 'get_settings') {
return options.settings ?? { env: {} }
@@ -130,7 +147,8 @@ function adapterFor(
claude: ReturnType<typeof fakeClaude>,
launch: Partial<ClaudeStructuredLaunch> = {},
events: ClaudeStructuredSessionEvent[] = [],
persistedHandles: unknown[] = []
persistedHandles: unknown[] = [],
initTimeoutMs?: number
): ClaudeStructuredSessionAdapter {
return new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
@@ -147,6 +165,7 @@ function adapterFor(
openConnection: claude.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
now: () => 1_700_000_000_500,
...(initTimeoutMs === undefined ? {} : { initTimeoutMs }),
dispatchAckTimeoutMs: 10,
persistHandle: async (handle) => {
persistedHandles.push(handle)
@@ -165,6 +184,10 @@ async function acquired(
}
describe('ClaudeStructuredSessionAdapter.acquire', () => {
it('finishes its startup deadline before the paired mobile request deadline', () => {
expect(CLAUDE_STRUCTURED_INIT_TIMEOUT_MS).toBeLessThan(30_000)
})
it('pins the account, proves init, and reports the process and chain leaf', async () => {
const claude = fakeClaude()
const events: ClaudeStructuredSessionEvent[] = []
@@ -224,6 +247,28 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
})
})
it('accepts SessionStart as the pre-turn session proof from the real CLI protocol', async () => {
const claude = fakeClaude({ initProof: 'session-start', initUuid: 'session-start-uuid' })
const events: ClaudeStructuredSessionEvent[] = []
const adapter = adapterFor(claude, {}, events)
const acquisition = await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9'
})
expect(acquisition.link.handle).toEqual({
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
leafUuid: 'session-start-uuid'
})
expect(events[0]).toMatchObject({
type: 'message',
message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' }
})
})
it('records only non-secret effective auth-lane diagnostics', async () => {
const claude = fakeClaude({
settings: {
@@ -287,6 +332,38 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
).rejects.toThrow('Claude login required')
expect(claude.connections[0].closeCount).toBe(1)
})
it('closes a silent unauthenticated startup with actionable account guidance', async () => {
const claude = fakeClaude({ initProof: 'none' })
const adapter = adapterFor(claude, {}, [], [], 20)
const error = await adapter
.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal)
expect(error).toMatchObject({
message: expect.stringMatching(/selected Claude account is signed in.*CLAUDE_CONFIG_DIR/s)
})
expect(claude.connections[0].calls[0]).toEqual({
subtype: 'initialize',
params: { supportedDialogKinds: [] }
})
expect(claude.connections[0].closeCount).toBe(1)
})
it('refuses an unauthenticated initialize response even when SessionStart runs', async () => {
const claude = fakeClaude({
initProof: 'session-start',
initAccount: { apiProvider: 'firstParty', tokenSource: 'none' }
})
const adapter = adapterFor(claude)
await expect(
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s)
expect(claude.connections[0].closeCount).toBe(1)
})
})
describe('ClaudeStructuredSessionAdapter turns and controls', () => {
@@ -17,7 +17,10 @@ import {
readClaudeInit,
readClaudeModels
} from './claude-structured-init-proof'
import { createClaudeInitDeadline } from './claude-structured-init-deadline'
import {
createClaudeInitDeadline,
requestClaudeInitialization
} from './claude-structured-init-deadline'
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity'
import { setClaudeStructuredOption } from './claude-structured-options'
@@ -45,7 +48,7 @@ export type {
ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
const INIT_TIMEOUT_MS = 30_000
export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000
const DISPATCH_ACK_TIMEOUT_MS = 10_000
export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter {
@@ -68,7 +71,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
const { previous, attempt } = this.acquisitions.start(sessionId, prompts)
let liveSession: ClaudeSession | null = null
let observedLeafUuid: string | null = null
const initDeadline = createClaudeInitDeadline(sessionId, INIT_TIMEOUT_MS)
const initTimeoutMs = this.deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS
const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs)
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
@@ -141,11 +145,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
this.acquisitions.assertCurrent(sessionId, attempt)
initDeadline.start()
const [initialization, init] = await Promise.all([
connection.request(
'initialize',
{ supportedDialogKinds: [] },
{ timeoutMs: this.deps.requestTimeoutMs }
),
requestClaudeInitialization(connection, sessionId, initTimeoutMs),
initDeadline.promise
])
const models = readClaudeModels(initialization)
@@ -41,6 +41,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
mintLinkId?: () => string
now?: () => number
requestTimeoutMs?: number
initTimeoutMs?: number
dispatchAckTimeoutMs?: number
persistHandle?: (input: {
sessionId: string
@@ -16,9 +16,22 @@ import type {
AgentSessionExecutionLocation,
AgentSessionProcessIdentity
} from '../../../shared/agent-session-record'
import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire'
import type {
AgentSessionOptionsResult,
AgentSessionWireRefusalCode
} from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
export class AgentSessionAcquisitionRefusal extends Error {
constructor(
message: string,
readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid'
) {
super(message)
this.name = 'AgentSessionAcquisitionRefusal'
}
}
/** What a reservation turns into once something is actually running under it:
* the process the host can probe, and the provider handle it was minted with. */
export type AgentSessionAcquisition = {
@@ -24,7 +24,9 @@ import {
} from './structured-agent-session-attach'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
export type AttachFlowInput = {
store: AgentSessionRecordStore
@@ -72,10 +74,29 @@ export async function performAttach(
)
record = reserved.record
replayed = reserved.disposition === 'replayed'
if (replayed && reserved.operationRow.outcome.status === 'failed') {
const replay = resolveAgentSessionReplayOutcome({
operationId: params.envelope.clientOperationId,
outcome: reserved.operationRow.outcome,
reconstruct: () => null
})
if (replay.decision === 'refuse') {
return { ok: false, refusal: replay.refusal }
}
}
if (!agentSessionLeaseAdmitsWriter(record.lease)) {
record = await acquireOwner(input, record)
}
} catch (error) {
if (error instanceof AgentSessionAcquisitionRefusal) {
const refusal = { code: error.code, message: error.message }
await store.recordOperationOutcome({
callerKey: input.callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'failed', code: refusal.code, message: refusal.message }
})
return { ok: false, refusal }
}
return {
ok: false,
refusal: classifyStoreFailure(error, store.getRecord(sessionId)?.lease.runtimeFence ?? null)
@@ -25,7 +25,7 @@ export function resolveAgentSessionReplayOutcome<TValue>(input: {
decision: 'refuse',
refusal: {
code,
message: `Operation ${operationId} was already refused: ${outcome.code}.`
message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.`
}
}
}
@@ -44,6 +44,7 @@ type FakeClaudeConnection = Omit<ClaudeStreamJsonConnection, 'closed'> & {
function fakeClaude() {
const connections: FakeClaudeConnection[] = []
let initializeAccount: unknown
const openConnection = (async (launch, handlers = {}) => {
const connection: FakeClaudeConnection = {
launch,
@@ -64,7 +65,10 @@ function fakeClaude() {
model: 'claude-sonnet-5',
apiKeySource: 'none'
})
return { models: [{ value: 'sonnet', displayName: 'Sonnet' }] }
return {
models: [{ value: 'sonnet', displayName: 'Sonnet' }],
...(initializeAccount === undefined ? {} : { account: initializeAccount })
}
}
return subtype === 'get_settings' ? { env: {} } : {}
},
@@ -92,7 +96,14 @@ function fakeClaude() {
}
return connection
}
return { connections, openConnection, live }
return {
connections,
openConnection,
live,
setInitializeAccount: (account: unknown) => {
initializeAccount = account
}
}
}
let operations = 0
@@ -267,6 +278,27 @@ afterEach(async () => {
})
describe('a structured Claude session over agentSession.*', () => {
it('durably returns actionable sign-in guidance when initialization has no credentials', async () => {
claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' })
const params = createIntentParams()
const first = await call('agentSession.create', params)
const retry = await call('agentSession.create', params)
expect(first).toMatchObject({
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_operation_invalid',
message: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s)
}
}
})
expect((retry as { result: unknown }).result).toEqual((first as { result: unknown }).result)
expect(claude.connections).toHaveLength(1)
})
it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => {
const created = await ok<{ fence: number }>('agentSession.create', createIntentParams())
expect(claude.live().launch.args).toContain('--session-id')
+1 -1
View File
@@ -20,7 +20,7 @@ export const AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT = 4_096
export type AgentSessionOperationOutcome =
| { status: 'pending' }
| { status: 'succeeded'; sessionId: string }
| { status: 'failed'; code: string }
| { status: 'failed'; code: string; message?: string }
/** The effect may or may not have happened; replay this answer instead of spawning again. */
| { status: 'unknown' }