feat(worker): ClaudeCodeAdapter runs claude -p stream-json in the per-user box
This commit is contained in:
@@ -357,6 +357,114 @@ export const normalizeCodexJsonLine = (
|
||||
return events;
|
||||
};
|
||||
|
||||
// Claude Code `--output-format stream-json` emits one JSON object per line:
|
||||
// `system:init` announces the session id, `assistant` messages carry text and
|
||||
// tool_use blocks, `user` messages carry tool_result blocks, and `result`
|
||||
// terminates the turn (success with final text, or an `error_*` subtype).
|
||||
export const normalizeClaudeJsonLine = (
|
||||
line: string,
|
||||
): NormalizedAgentEvent[] => {
|
||||
if (!line.trim()) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return [{ kind: 'status', status: line }];
|
||||
}
|
||||
const event = asRecord(parsed);
|
||||
if (!event) return [{ kind: 'status', status: line }];
|
||||
const type = stringify(event.type);
|
||||
const subtype = stringify(event.subtype);
|
||||
const events: NormalizedAgentEvent[] = [];
|
||||
|
||||
if (type === 'system') {
|
||||
const sessionId = stringify(event.session_id);
|
||||
if (subtype === 'init' && sessionId) {
|
||||
events.push({ kind: 'session', sessionId });
|
||||
}
|
||||
if (events.length === 0) {
|
||||
events.push({ kind: 'status', status: subtype || 'system' });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if (type === 'assistant') {
|
||||
const message = asRecord(event.message);
|
||||
const content = message?.content;
|
||||
let text = '';
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
const record = asRecord(block);
|
||||
if (!record) continue;
|
||||
const blockType = stringify(record.type);
|
||||
if (blockType === 'text') {
|
||||
text += textFromPart(record);
|
||||
} else if (blockType === 'tool_use') {
|
||||
events.push({
|
||||
kind: 'tool_started',
|
||||
name: stringify(record.name ?? 'tool'),
|
||||
input: JSON.stringify(record.input ?? {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text) events.unshift({ kind: 'assistant_delta', content: text });
|
||||
if (events.length === 0) {
|
||||
events.push({ kind: 'status', status: 'assistant' });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if (type === 'content_block_delta') {
|
||||
const delta = asRecord(event.delta);
|
||||
const text = delta ? textFromPart(delta) : '';
|
||||
if (text) events.push({ kind: 'assistant_delta', content: text });
|
||||
if (events.length === 0) {
|
||||
events.push({ kind: 'status', status: type });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if (type === 'user') {
|
||||
const message = asRecord(event.message);
|
||||
const content = message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
const record = asRecord(block);
|
||||
if (!record || stringify(record.type) !== 'tool_result') continue;
|
||||
events.push({
|
||||
kind: 'tool_completed',
|
||||
name: 'tool',
|
||||
output: toolOutputFromRecord(record),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (events.length === 0) {
|
||||
events.push({ kind: 'status', status: 'user' });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if (type === 'result') {
|
||||
const sessionId = stringify(event.session_id);
|
||||
if (sessionId) events.push({ kind: 'session', sessionId });
|
||||
if (subtype === 'success') {
|
||||
events.push({
|
||||
kind: 'assistant_completed',
|
||||
content: stringify(event.result),
|
||||
});
|
||||
} else {
|
||||
events.push({
|
||||
kind: 'error',
|
||||
message: stringify(event.result ?? event.error ?? subtype),
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
return [{ kind: 'status', status: type || line }];
|
||||
};
|
||||
|
||||
export const normalizeOpenCodeEvent = (
|
||||
input: unknown,
|
||||
): NormalizedAgentEvent[] => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
||||
// permissions. Shared by the credential-writing adapters (Claude, and available
|
||||
// to Codex/OpenCode). `label` names the source in the parse-error message.
|
||||
export const writeJsonFile = async (
|
||||
filePath: string,
|
||||
content: string,
|
||||
label = 'auth',
|
||||
) => {
|
||||
let normalized = content.trim();
|
||||
try {
|
||||
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
|
||||
} catch {
|
||||
throw new Error(`${label} JSON is not valid JSON.`);
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, normalized, { mode: 0o600 });
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { NormalizedAgentEvent } from '../agent-events';
|
||||
import { normalizeClaudeJsonLine } from '../agent-events';
|
||||
import { env } from '../env';
|
||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||
import { writeJsonFile } from './auth';
|
||||
import {
|
||||
buildMarkedCommand,
|
||||
killBoxProcessesByMarker,
|
||||
streamExecInContainer,
|
||||
} from './docker';
|
||||
import type { AdapterWorkspace } from './provider';
|
||||
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
|
||||
|
||||
export const createClaudeAdapter: AdapterFactory = (deps) => {
|
||||
const execStream = deps?.execStream ?? streamExecInContainer;
|
||||
|
||||
const prepareAuth = async (workspace: AdapterWorkspace) => {
|
||||
// API-key profiles authenticate purely via ANTHROPIC_API_KEY in the
|
||||
// environment; only OAuth-json profiles need `.credentials.json` on disk.
|
||||
if (!isClaudeOAuthProfile(workspace.claim)) return;
|
||||
const secret = workspace.claim.aiProviderProfile?.secret;
|
||||
if (!secret) {
|
||||
throw new Error('Claude auth profile is missing credentials.json contents.');
|
||||
}
|
||||
const credentialsPath = path.join(
|
||||
workspace.homeDir,
|
||||
'.claude',
|
||||
'.credentials.json',
|
||||
);
|
||||
await writeJsonFile(credentialsPath, secret, 'Claude auth');
|
||||
};
|
||||
|
||||
const runTurn = async (
|
||||
workspace: AdapterWorkspace,
|
||||
prompt: string,
|
||||
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||
): Promise<TurnResult> => {
|
||||
workspace.runtime = 'claude';
|
||||
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
|
||||
const marker = workspace.turnMarker;
|
||||
|
||||
// NOTE: exact flags are UNVERIFIED against the pinned CLI and are confirmed
|
||||
// in Task 11's smoke. `--verbose` is required for `stream-json`;
|
||||
// `--resume <sessionId>` carries continuity.
|
||||
const argv = [
|
||||
'claude',
|
||||
'-p',
|
||||
prompt,
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--dangerously-skip-permissions',
|
||||
'--model',
|
||||
claudeModel(workspace.claim),
|
||||
...(workspace.claudeSessionId
|
||||
? ['--resume', workspace.claudeSessionId]
|
||||
: []),
|
||||
];
|
||||
const command = buildMarkedCommand(marker, argv);
|
||||
|
||||
const aiEnv = claudeEnv(workspace.claim, workspace.containerHome);
|
||||
const secretEnv = Object.fromEntries(
|
||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
|
||||
let capturedSessionId: string | undefined;
|
||||
let capturedError: string | undefined;
|
||||
let finalMessage: string | undefined;
|
||||
|
||||
// execa's own `timeout` only kills the local docker-exec client, not the
|
||||
// in-box process group. Schedule an explicit marker kill on timeout and clear
|
||||
// it once the exec resolves.
|
||||
const killTimer = setTimeout(() => {
|
||||
void killBoxProcessesByMarker({
|
||||
containerName: workspace.boxName,
|
||||
marker,
|
||||
});
|
||||
}, env.jobTimeoutMs);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await execStream({
|
||||
containerName: workspace.boxName,
|
||||
containerCwd: workspace.containerRepo,
|
||||
command,
|
||||
environment: { ...aiEnv, ...secretEnv },
|
||||
redact: workspace.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
onStdoutLine: async (line) => {
|
||||
for (const event of normalizeClaudeJsonLine(line)) {
|
||||
if (event.kind === 'session') capturedSessionId = event.sessionId;
|
||||
if (event.kind === 'error') capturedError = event.message;
|
||||
if (event.kind === 'assistant_completed' && event.content) {
|
||||
finalMessage = event.content;
|
||||
}
|
||||
await onEvent(event);
|
||||
}
|
||||
},
|
||||
onStderrLine: async (line) => {
|
||||
if (!line.trim()) return;
|
||||
await onEvent({ kind: 'status', status: line });
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
capturedError = result.output;
|
||||
}
|
||||
|
||||
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
|
||||
};
|
||||
|
||||
const abort = async (workspace: AdapterWorkspace) => {
|
||||
if (!workspace.turnMarker) return;
|
||||
await killBoxProcessesByMarker({
|
||||
containerName: workspace.boxName,
|
||||
marker: workspace.turnMarker,
|
||||
});
|
||||
};
|
||||
|
||||
return { name: 'claude', prepareAuth, runTurn, abort };
|
||||
};
|
||||
@@ -169,3 +169,31 @@ export const codexModel = (claim: Claim) => {
|
||||
|
||||
export const codexModelArgs = (claim: Claim) =>
|
||||
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
|
||||
|
||||
// Claude Code OAuth profiles store their credentials as a JSON blob written to
|
||||
// `~/.claude/.credentials.json`; API-key profiles authenticate via
|
||||
// ANTHROPIC_API_KEY. Mirrors the OpenCode adapter's `opencode_auth_json` signal.
|
||||
export const isClaudeOAuthProfile = (claim: Claim) =>
|
||||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
|
||||
|
||||
// The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
|
||||
// so strip any `provider/` prefix like codexModel does.
|
||||
export const claudeModel = (claim: Claim) => {
|
||||
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
|
||||
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
|
||||
};
|
||||
|
||||
// Environment for a `claude -p` turn. OAuth-json profiles authenticate from the
|
||||
// on-disk credentials file (only HOME is needed); API-key profiles export
|
||||
// ANTHROPIC_API_KEY.
|
||||
export const claudeEnv = (
|
||||
claim: Claim,
|
||||
containerHome: string,
|
||||
): Record<string, string> => {
|
||||
if (isClaudeOAuthProfile(claim)) return { HOME: containerHome };
|
||||
const secret = claim.aiProviderProfile?.secret ?? claim.openai.apiKey;
|
||||
if (!secret) {
|
||||
throw new Error('No Anthropic API key is configured for this Claude job.');
|
||||
}
|
||||
return { ANTHROPIC_API_KEY: secret, HOME: containerHome };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { registerAdapter } from './agent-runtime';
|
||||
import { createClaudeAdapter } from './claude-adapter';
|
||||
import { createCodexAdapter } from './codex-adapter';
|
||||
import { createOpenCodeAdapter } from './opencode-adapter';
|
||||
|
||||
registerAdapter('codex', createCodexAdapter);
|
||||
registerAdapter('opencode', createOpenCodeAdapter);
|
||||
registerAdapter('claude', createClaudeAdapter);
|
||||
|
||||
Reference in New Issue
Block a user