mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
fix: harden Claude structured runtime
This commit is contained in:
@@ -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' }] }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user