mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix: close Claude adapter edge cases
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
|
||||
|
||||
describe('Claude child process environment', () => {
|
||||
it('strips case-insensitive inherited auth and session stamps on Windows', () => {
|
||||
const env = buildClaudeChildProcessEnv(
|
||||
{
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
Claude_Code_Session_Id: 'configured-session'
|
||||
},
|
||||
{
|
||||
platform: 'win32',
|
||||
inheritedEnv: {
|
||||
anthropic_api_key: 'inherited-key',
|
||||
Anthropic_Custom_Headers: 'Authorization: inherited',
|
||||
claude_code_child_session: '1',
|
||||
CLAUDE_CODE_SESSION_ID: 'inherited-session',
|
||||
SAFE_VALUE: 'preserved'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(env).toEqual({
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
Claude_Code_Session_Id: 'configured-session',
|
||||
SAFE_VALUE: 'preserved'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
import { CLAUDE_AUTH_ENV_VARS, applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
|
||||
const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [
|
||||
'CLAUDE_CODE_CHILD_SESSION',
|
||||
@@ -6,9 +6,9 @@ const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [
|
||||
'CLAUDE_CODE_BRIDGE_SESSION_ID'
|
||||
] as const
|
||||
|
||||
function cloneProcessEnv(): Record<string, string> {
|
||||
function cloneProcessEnv(source: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value !== undefined) {
|
||||
env[key] = value
|
||||
}
|
||||
@@ -17,11 +17,31 @@ function cloneProcessEnv(): Record<string, string> {
|
||||
}
|
||||
|
||||
export function buildClaudeChildProcessEnv(
|
||||
configuredEnv: Record<string, string> = {}
|
||||
configuredEnv: Record<string, string> = {},
|
||||
options: { inheritedEnv?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
|
||||
): Record<string, string> {
|
||||
const env = applyClaudeEnvPatch(cloneProcessEnv(), {}, { stripAuthEnv: true })
|
||||
const inheritedEnv = options.inheritedEnv ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
const env = applyClaudeEnvPatch(cloneProcessEnv(inheritedEnv), {}, { stripAuthEnv: true })
|
||||
if (platform === 'win32') {
|
||||
const authKeys = new Set(CLAUDE_AUTH_ENV_VARS.map((key) => key.toUpperCase()))
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const normalized = key.toUpperCase()
|
||||
if (
|
||||
authKeys.has(normalized) ||
|
||||
(normalized === 'ANTHROPIC_CUSTOM_HEADERS' &&
|
||||
/authorization|x-api-key|api-key|bearer/i.test(value))
|
||||
) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) {
|
||||
delete env[key]
|
||||
for (const inheritedKey of Object.keys(env)) {
|
||||
if (inheritedKey === key || (platform === 'win32' && inheritedKey.toUpperCase() === key)) {
|
||||
delete env[inheritedKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.assign(env, configuredEnv)
|
||||
}
|
||||
|
||||
@@ -119,15 +119,17 @@ describe('Claude stream-json connection', () => {
|
||||
it('fails closed on malformed provider output', async () => {
|
||||
const process = fakeSpawn()
|
||||
const onExit = vi.fn()
|
||||
const onMessage = vi.fn()
|
||||
await openClaudeStreamJsonConnection(
|
||||
{ command: 'claude', args: [], cwd: '/work' },
|
||||
{ onExit },
|
||||
{ onExit, onMessage },
|
||||
process.spawnImpl
|
||||
)
|
||||
process.child.stdout.write('{not-json}\n')
|
||||
process.child.stdout.write('{not-json}\n{"type":"assistant"}\n')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(process.child.kill).toHaveBeenCalled()
|
||||
expect(process.child.kill).toHaveBeenCalledTimes(1)
|
||||
expect(onMessage).not.toHaveBeenCalled()
|
||||
expect(onExit).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) }))
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline'
|
||||
import { killCodexAppServerProcessTree } from '../codex/codex-app-server-session'
|
||||
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
|
||||
import { attachClaudeStreamJsonStdout } from './claude-stream-json-stdout'
|
||||
|
||||
export type ClaudeStreamJsonLaunch = {
|
||||
command: string
|
||||
@@ -64,17 +65,14 @@ type PendingRequest = {
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function exitError(stderrTail: string, cause?: Error): Error {
|
||||
const detail = stderrTail.trim()
|
||||
const message = detail ? `claude stream-json exited: ${detail}` : 'claude stream-json exited'
|
||||
return cause ? new Error(message, { cause }) : new Error(message)
|
||||
}
|
||||
|
||||
export async function openClaudeStreamJsonConnection(
|
||||
launch: ClaudeStreamJsonLaunch,
|
||||
handlers: ClaudeStreamJsonConnectionHandlers = {},
|
||||
@@ -89,7 +87,6 @@ export async function openClaudeStreamJsonConnection(
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
let nextRequestId = 1
|
||||
let stderrTail = ''
|
||||
let stdoutBuffer = ''
|
||||
let exited = false
|
||||
let closing = false
|
||||
let terminalError: Error | null = null
|
||||
@@ -179,41 +176,13 @@ export async function openClaudeStreamJsonConnection(
|
||||
}
|
||||
handlers.onMessage?.(message)
|
||||
}
|
||||
|
||||
const parseLine = (line: string): void => {
|
||||
if (!line.trim()) {
|
||||
return
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch (error) {
|
||||
const stdout = attachClaudeStreamJsonStdout({
|
||||
stdout: child.stdout,
|
||||
maxLineBytes: STDOUT_LINE_MAX_BYTES,
|
||||
onMessage: dispatchMessage,
|
||||
onFailure: (error) => {
|
||||
killCodexAppServerProcessTree(child)
|
||||
handleUnexpectedEnd(new Error('claude emitted invalid stream-json', { cause: error }))
|
||||
return
|
||||
}
|
||||
if (isRecord(parsed)) {
|
||||
dispatchMessage(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stdoutBuffer += chunk
|
||||
let newline = stdoutBuffer.indexOf('\n')
|
||||
while (newline !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newline)
|
||||
stdoutBuffer = stdoutBuffer.slice(newline + 1)
|
||||
if (Buffer.byteLength(line, 'utf8') > STDOUT_LINE_MAX_BYTES) {
|
||||
killCodexAppServerProcessTree(child)
|
||||
handleUnexpectedEnd(new Error('claude emitted an oversized stream-json line'))
|
||||
return
|
||||
}
|
||||
parseLine(line)
|
||||
newline = stdoutBuffer.indexOf('\n')
|
||||
}
|
||||
if (Buffer.byteLength(stdoutBuffer, 'utf8') > STDOUT_LINE_MAX_BYTES) {
|
||||
killCodexAppServerProcessTree(child)
|
||||
handleUnexpectedEnd(new Error('claude emitted an oversized stream-json line'))
|
||||
handleUnexpectedEnd(error)
|
||||
}
|
||||
})
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
@@ -225,10 +194,7 @@ export async function openClaudeStreamJsonConnection(
|
||||
})
|
||||
child.on('close', () => {
|
||||
markExited()
|
||||
if (stdoutBuffer.trim()) {
|
||||
parseLine(stdoutBuffer)
|
||||
stdoutBuffer = ''
|
||||
}
|
||||
stdout.flush()
|
||||
handleUnexpectedEnd()
|
||||
})
|
||||
child.stdin.on('error', (error) => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
|
||||
export type ClaudeStreamJsonStdout = {
|
||||
flush: () => void
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function attachClaudeStreamJsonStdout(input: {
|
||||
stdout: Readable
|
||||
maxLineBytes: number
|
||||
onMessage: (message: Record<string, unknown>) => void
|
||||
onFailure: (error: Error) => void
|
||||
}): ClaudeStreamJsonStdout {
|
||||
let buffer = ''
|
||||
let stopped = false
|
||||
|
||||
const stop = (): void => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
stopped = true
|
||||
buffer = ''
|
||||
input.stdout.removeListener('data', onData)
|
||||
input.stdout.pause()
|
||||
}
|
||||
const fail = (error: Error): void => {
|
||||
stop()
|
||||
input.onFailure(error)
|
||||
}
|
||||
const parseLine = (line: string): void => {
|
||||
if (!line.trim()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line)
|
||||
if (isRecord(parsed)) {
|
||||
input.onMessage(parsed)
|
||||
}
|
||||
} catch (error) {
|
||||
fail(new Error('claude emitted invalid stream-json', { cause: error }))
|
||||
}
|
||||
}
|
||||
const onData = (chunk: string): void => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
buffer += chunk
|
||||
let newline = buffer.indexOf('\n')
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline)
|
||||
buffer = buffer.slice(newline + 1)
|
||||
if (Buffer.byteLength(line, 'utf8') > input.maxLineBytes) {
|
||||
fail(new Error('claude emitted an oversized stream-json line'))
|
||||
return
|
||||
}
|
||||
parseLine(line)
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
newline = buffer.indexOf('\n')
|
||||
}
|
||||
if (Buffer.byteLength(buffer, 'utf8') > input.maxLineBytes) {
|
||||
fail(new Error('claude emitted an oversized stream-json line'))
|
||||
}
|
||||
}
|
||||
|
||||
input.stdout.setEncoding('utf8').on('data', onData)
|
||||
return {
|
||||
flush: () => {
|
||||
if (!stopped && buffer.trim()) {
|
||||
const line = buffer
|
||||
buffer = ''
|
||||
parseLine(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,4 +64,24 @@ describe('Claude structured dispatch image limits', () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a local image by actual bytes read beyond the per-image cap', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
|
||||
try {
|
||||
const path = join(directory, 'oversized.png')
|
||||
await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1))
|
||||
const session = sessionFor()
|
||||
const body = userMessage([{ type: 'image-ref', path }])
|
||||
|
||||
await expect(
|
||||
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
|
||||
).resolves.toEqual({
|
||||
state: 'rejected',
|
||||
reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes`
|
||||
})
|
||||
expect(session.connection.send).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { extname } from 'node:path'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { open } from 'node:fs/promises'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import type { NativeChatBlock } from '../../shared/native-chat-types'
|
||||
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
@@ -15,6 +15,33 @@ type ImageBudget = {
|
||||
localBytes: number
|
||||
}
|
||||
|
||||
async function readClaudeImage(path: string): Promise<Buffer> {
|
||||
const file = await open(path, 'r')
|
||||
try {
|
||||
const info = await file.stat()
|
||||
if (!info.isFile()) {
|
||||
throw new Error('Claude image must be a file')
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(MAX_IMAGE_BYTES + 1)
|
||||
let bytesRead = 0
|
||||
while (bytesRead < buffer.length) {
|
||||
const result = await file.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead)
|
||||
if (result.bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
bytesRead += result.bytesRead
|
||||
}
|
||||
if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES) {
|
||||
throw new Error(
|
||||
`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`
|
||||
)
|
||||
}
|
||||
return buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
@@ -56,11 +83,8 @@ async function imageContent(
|
||||
if (!block.path) {
|
||||
throw new Error('image reference has neither a path nor a URL')
|
||||
}
|
||||
const info = await stat(block.path)
|
||||
if (!info.isFile() || info.size > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`Claude image must be a file no larger than ${MAX_IMAGE_BYTES} bytes`)
|
||||
}
|
||||
budget.localBytes += info.size
|
||||
const data = await readClaudeImage(block.path)
|
||||
budget.localBytes += data.byteLength
|
||||
if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) {
|
||||
throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`)
|
||||
}
|
||||
@@ -73,7 +97,7 @@ async function imageContent(
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mediaType,
|
||||
data: (await readFile(block.path)).toString('base64')
|
||||
data: data.toString('base64')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createClaudeAcquisitionAttempt } from './claude-structured-session-state'
|
||||
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import { handleClaudeInboundControl } from './claude-structured-inbound-control'
|
||||
|
||||
function rejectingAttempt() {
|
||||
const attempt = createClaudeAcquisitionAttempt(new ClaudePromptRegistry())
|
||||
const respond = vi.fn().mockRejectedValue(new Error('connection closed'))
|
||||
const respondWithError = vi.fn().mockRejectedValue(new Error('connection closed'))
|
||||
attempt.connection = { respond, respondWithError } as never
|
||||
return { attempt, respond, respondWithError }
|
||||
}
|
||||
|
||||
describe('Claude inbound control', () => {
|
||||
it('consumes rejected writes while declining unsupported controls', async () => {
|
||||
const dialog = rejectingAttempt()
|
||||
handleClaudeInboundControl({
|
||||
sessionId: 'session-1',
|
||||
attempt: dialog.attempt,
|
||||
request: {
|
||||
type: 'control_request',
|
||||
request_id: 'dialog-1',
|
||||
request: { subtype: 'request_user_dialog' }
|
||||
},
|
||||
emit: vi.fn()
|
||||
})
|
||||
const unsupported = rejectingAttempt()
|
||||
handleClaudeInboundControl({
|
||||
sessionId: 'session-1',
|
||||
attempt: unsupported.attempt,
|
||||
request: {
|
||||
type: 'control_request',
|
||||
request_id: 'control-1',
|
||||
request: { subtype: 'future_control' }
|
||||
},
|
||||
emit: vi.fn()
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(dialog.respond).toHaveBeenCalledWith('dialog-1', { behavior: 'cancelled' })
|
||||
expect(unsupported.respondWithError).toHaveBeenCalledWith(
|
||||
'control-1',
|
||||
'Orca does not handle future_control'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -12,15 +12,17 @@ export function handleClaudeInboundControl(input: {
|
||||
}): void {
|
||||
const connection = input.attempt.connection
|
||||
if (input.request.request.subtype === 'request_user_dialog') {
|
||||
void connection?.respond(input.request.request_id, { behavior: 'cancelled' })
|
||||
void connection?.respond(input.request.request_id, { behavior: 'cancelled' }).catch(() => {})
|
||||
return
|
||||
}
|
||||
const prompt = input.attempt.prompts.register(input.request)
|
||||
if (!prompt) {
|
||||
void connection?.respondWithError(
|
||||
input.request.request_id,
|
||||
`Orca does not handle ${input.request.request.subtype}`
|
||||
)
|
||||
void connection
|
||||
?.respondWithError(
|
||||
input.request.request_id,
|
||||
`Orca does not handle ${input.request.request.subtype}`
|
||||
)
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
input.emit({ type: 'prompt', sessionId: input.sessionId, prompt })
|
||||
|
||||
Reference in New Issue
Block a user