fix: harden Claude structured runtime

This commit is contained in:
Brennan Benson
2026-08-10 04:25:54 -07:00
parent 23ce5e5a67
commit 83d73bd0f4
9 changed files with 94 additions and 8 deletions
@@ -16,7 +16,8 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession
dispatchWaiters: [],
options: new Map(),
reportedOptions: {},
events: undefined
events: undefined,
translator: null
}
}
@@ -5,6 +5,10 @@ import type {
} from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import type { ClaudePendingPrompt } from './claude-structured-prompt-replies'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
@@ -139,6 +143,19 @@ describe('Claude structured journal translation', () => {
})
})
it('bounds persisted thinking text to the shared journal payload limit', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
const thinking = 'considering '.repeat(20_000)
translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }]))
expect(state.items.at(-1)?.body).toEqual({
kind: 'status',
text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
})
})
it('creates addressable approval and multi-question cards and cancels them durably', () => {
const state = sinkState()
const bindings: unknown[][] = []
@@ -188,6 +205,31 @@ describe('Claude structured journal translation', () => {
'Ship?'
])
const multiSelect = prompt({
requestId: 'questions-multi',
promptKey: 'questions-multi',
toolUseId: 'tool-multi',
toolName: 'AskUserQuestion',
kind: 'question',
input: {
questions: [
{
question: 'Libraries?',
multiSelect: true,
options: [{ label: 'Luxon' }, { label: 'Temporal' }]
}
]
},
questionIds: ['Libraries?']
})
translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: multiSelect })
expect(state.items.at(-1)?.body).toMatchObject({
kind: 'question',
question: 'Libraries?\n\nEnter one or more choices separated by commas.',
options: [],
freeTextQuestionId: 'Libraries?'
})
translator.handle({
type: 'prompt-cancelled',
sessionId: 'orca-session',
@@ -5,6 +5,10 @@ import {
type AgentSessionDeltaCoalescerDeps
} from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import {
claudeMessageBody,
@@ -187,7 +191,7 @@ export function createClaudeJournalTranslator(
if (thinking) {
deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
kind: 'status',
text: thinking
text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
})
changed = true
}
@@ -29,11 +29,12 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
} as AgentSessionRecord
}
function resolverFor(value: AgentSessionRecord | null) {
function resolverFor(value: AgentSessionRecord | null, resolveEnv?: () => Record<string, string>) {
return createClaudeStructuredLaunchResolver({
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => '/usr/local/bin/claude'
resolveCommand: () => '/usr/local/bin/claude',
...(resolveEnv ? { resolveEnv } : {})
})
}
@@ -83,6 +84,22 @@ describe('claude structured launch resolution', () => {
expect(launch.args.slice(-2)).toEqual(['--resume', 'provider-current'])
})
it('resolves configured environment live without storing it in the session record', async () => {
let token = 'first-token'
const resolver = resolverFor(record(), () => ({
ANTHROPIC_AUTH_TOKEN: token,
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
}))
expect((await resolver({ identity: IDENTITY })).env).toEqual({
ANTHROPIC_AUTH_TOKEN: 'first-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
})
token = 'rotated-token'
expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token')
expect(JSON.stringify(record())).not.toContain('token')
})
it('refuses other hosts, WSL, providers, and account-home variables', async () => {
await expect(
resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({
@@ -38,6 +38,7 @@ export type ClaudeStructuredLaunchResolverDeps = {
store: AgentSessionRecordStore
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCommand?: () => string
resolveEnv?: () => Record<string, string>
}
export function claudeSessionIdForOrcaSession(sessionId: string): string {
@@ -85,6 +86,7 @@ export function createClaudeStructuredLaunchResolver(
command: spawnCmd,
args: spawnArgs,
cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
...(deps.resolveEnv ? { env: deps.resolveEnv() } : {}),
claudeConfigDir: record.accountHome.path,
providerSessionId,
resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null,
@@ -85,6 +85,7 @@ export function claudeQuestionItems(input: {
const question = claudeRecord(value)
const questionId = input.prompt.questionIds[index]
const text = claudeText(question?.question) ?? claudeText(question?.header)
const multiSelect = question?.multiSelect === true
return question && questionId && text
? [
{
@@ -96,8 +97,10 @@ export function claudeQuestionItems(input: {
}),
body: {
kind: 'question',
question: text,
options: questionOptions(question, questionId),
question: multiSelect
? `${text}\n\nEnter one or more choices separated by commas.`
: text,
options: multiSelect ? [] : questionOptions(question, questionId),
freeTextQuestionId: questionId,
resolution: { ...PENDING }
}
@@ -13,6 +13,7 @@ import type {
openClaudeStreamJsonConnection
} from '../claude/claude-stream-json-connection'
import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution'
import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity'
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 { OrcaRuntimeService } from './orca-runtime'
@@ -244,6 +245,10 @@ beforeEach(async () => {
resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`,
resolveCodexCommand: () => '/usr/local/bin/codex',
resolveClaudeCommand: () => '/usr/local/bin/claude',
resolveClaudeLaunchEnv: () => ({
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
}),
openClaudeConnection: claude.openConnection
}).then(() => undefined),
registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose),
@@ -266,6 +271,12 @@ describe('a structured Claude session over agentSession.*', () => {
const created = await ok<{ fence: number }>('agentSession.create', createIntentParams())
expect(claude.live().launch.args).toContain('--session-id')
expect(claude.live().launch.args).toContain(PROVIDER_SESSION)
expect(claude.live().launch.env).toEqual({
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
CLAUDE_CONFIG_DIR: '/accounts/claude',
[CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String)
})
const stream = await subscribe()
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] }
+5 -1
View File
@@ -9195,7 +9195,11 @@ export class OrcaRuntimeService {
// Resolves folder workspaces as well as git worktrees, so a chat session
// in a plain folder lands in the folder rather than failing to resolve.
resolveWorkspacePath: async (workspaceId) =>
(await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path
(await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path,
resolveClaudeLaunchEnv: () => {
const settings = this.requireStore().getSettings()
return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv)
}
})
}
@@ -42,6 +42,7 @@ export type StructuredAgentSessionRuntimeDeps = {
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCodexCommand?: () => string
resolveClaudeCommand?: () => string
resolveClaudeLaunchEnv?: () => Record<string, string>
/** Transport for the Codex child. Overridden only to drive the whole runtime
* against a scripted app-server; production spawns the real one. */
openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection']
@@ -104,7 +105,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
resolveLaunch: createClaudeStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {})
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
}),
...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}),
persistHandle: async (observed) => {