/** * Claude Code SDK agent runner. * * Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK * owns tool dispatch, context compaction, streaming — we just map its events * to the Feishu streaming callback. * * Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface: * - read_file → Read * - write_file → Write * - list_files → Glob * - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`) * No custom tools needed — the SDK's Bash tool runs cph directly. * * ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is * Anthropic-specific. The tradeoff: we get compaction, tool approval, partial * message streaming, and a battle-tested agent loop — capabilities we'd have * to build ourselves with a generic provider. * * ADR-0018: the agent execution surface is bounded by the run's workspace. * The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude * Code process and its subprocesses at the OS level — writes are confined to * the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are * denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox * can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized` * (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * is the mechanism, the contract pins the invariant. */ import { homedir } from "node:os"; import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import type { PrismaClient } from "@prisma/client"; import { claudeSdkToolConfigForRole } from "./roleTools.js"; export interface ProjectContext { readonly projectId: string; readonly boundChatId: string; readonly workspaceDir: string; } export type StreamEvent = | { readonly type: "text-delta"; readonly text: string } | { readonly type: "thinking-delta"; readonly text: string } | { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string } | { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number } | { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number } | { readonly type: "finish" }; export type StreamCallback = (event: StreamEvent) => void; export interface RunRequest { readonly prompt: string; readonly model: string | undefined; readonly project: ProjectContext; readonly systemPrompt: string | undefined; readonly providerEnv?: Record | undefined; readonly resumeSessionId?: string | undefined; /** * RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids * (for example read_file/cph_check/send_file), mapped to Claude SDK tool * names at run setup. `undefined` means the full supported tool surface; [] * means no tools. */ readonly tools?: readonly string[] | undefined; readonly mcpServers?: Record | undefined; readonly maxTurns?: number; readonly runId: string; readonly sessionId: string; readonly prisma: PrismaClient; readonly onStream?: StreamCallback; /** * Abort controller for the run. Aborting the signal interrupts the SDK query * (it stops the agent loop and cleans up); the run resolves with status * "interrupted". The caller owns the controller and triggers abort. */ readonly abortController?: AbortController | undefined; } export type RunStatus = "completed" | "length" | "failed" | "interrupted"; export interface RunResult { readonly status: RunStatus; readonly text: string; readonly usage: { inputTokens: number; outputTokens: number }; /** Provider- or gateway-reported cost for this run, in USD. */ readonly costUsd?: number | undefined; readonly numTurns: number; readonly sdkSessionId?: string | undefined; readonly error?: string; } const DEFAULT_MAX_TURNS = 25; /** * Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox * confines writes to the workspace; these deny reads of credentials/secrets * the process could otherwise see (read-only mounts are still readable). The * workspace itself is never in this list — it's writable by `allowWrite`. * Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments * with additional secret locations. */ const SENSITIVE_READ_PATHS: readonly string[] = (() => { const home = homedir(); const base = [ "/etc", "/root", "/var/log", "/proc", "/sys", `${home}/.ssh`, `${home}/.aws`, `${home}/.config/gcloud`, `${home}/.gnupg`, ]; const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"]; return extra !== undefined && extra !== "" ? [...base, ...extra.split(":").filter((p) => p !== "")] : base; })(); export async function runAgent(req: RunRequest): Promise { const onStream = req.onStream; const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; // Lock heartbeat: refresh on each step. Best-effort. const heartbeat = async () => { try { await req.prisma.projectAgentLock.update({ where: { runId: req.runId }, data: { heartbeatAt: new Date() }, }); } catch { /* best-effort */ } }; let fullText = ""; let usage = { inputTokens: 0, outputTokens: 0 }; let costUsd: number | undefined; let numTurns = 0; let sdkSessionId: string | undefined; let error: string | undefined; try { await persistAgentMessage(req, "user", req.prompt); const toolConfig = claudeSdkToolConfigForRole(req.tools); const options: Parameters[0]["options"] = { cwd: req.project.workspaceDir, tools: [...toolConfig.tools], allowedTools: [...toolConfig.allowedTools], maxTurns: cap, includePartialMessages: true, // ADR-0018: bypass interactive prompts (headless server); the sandbox // below is the hard boundary, not the permission-prompt layer. permissionMode: "bypassPermissions" as const, allowDangerouslySkipPermissions: true, // ADR-0018: OS-level sandbox confines the agent process + its Bash // subprocesses. Writes confined to the workspace; sensitive host paths // denied reads; network open. failIfUnavailable hard-fails if the // sandbox can't start — never run unsandboxed. sandbox: { enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: true, filesystem: { allowWrite: [req.project.workspaceDir], denyRead: [...SENSITIVE_READ_PATHS], }, }, }; if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt; if (req.model !== undefined) options.model = req.model; if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv }; if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId; if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers; if (req.abortController !== undefined) options.abortController = req.abortController; const conversation = query({ prompt: req.prompt, options, }); // Track tool start timestamps for duration calculation const toolStartTimestamps = new Map(); for await (const message of conversation) { switch (message.type) { case "stream_event": { const evt = (message as SDKPartialAssistantMessage).event; if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") { onStream?.({ type: "text-delta", text: evt.delta.text }); } if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") { onStream?.({ type: "thinking-delta", text: evt.delta.thinking }); } if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") { const toolUseId = evt.content_block.id; toolStartTimestamps.set(toolUseId, Date.now()); onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId }); } if (evt.type === "content_block_stop") { // Tool end is attributed from the assistant message below. } break; } case "assistant": { const msg = (message as SDKAssistantMessage).message; await heartbeat(); numTurns++; let assistantText = ""; for (const block of msg.content) { if (block.type === "text" && typeof block.text === "string") { fullText += block.text; assistantText += block.text; } if (block.type === "tool_use") { const durationMs = toolStartTimestamps.has(block.id) ? Date.now() - (toolStartTimestamps.get(block.id) ?? 0) : undefined; onStream?.({ type: "tool-end", toolName: block.name, toolUseId: block.id, input: block.input, ...(durationMs !== undefined ? { durationMs } : {}), }); } if (block.type === "thinking" && typeof block.thinking === "string") { // Complete thinking block from the assistant message. // Stream deltas already delivered this; no separate event needed. } } await persistAgentMessage(req, "assistant", assistantText); if (msg.usage !== undefined) { usage.inputTokens += msg.usage.input_tokens ?? 0; usage.outputTokens += msg.usage.output_tokens ?? 0; } break; } case "user": { // User messages carry tool_result blocks — what each tool returned. const msg = (message as SDKUserMessage).message; const content = msg.content; if (!Array.isArray(content)) break; for (const block of content) { if (typeof block !== "object" || block === null || block.type !== "tool_result") continue; // Narrowed to BetaToolResultBlockParam by discriminant + field presence if (!("tool_use_id" in block)) continue; const toolUseId = block.tool_use_id; const isError = block.is_error === true; const resultText = extractToolResultText(block.content); const durationMs = toolStartTimestamps.get(toolUseId); onStream?.({ type: "tool-result", toolUseId, toolName: toolUseId, result: resultText, isError, ...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}), }); } break; } case "result": { const result = message as SDKResultMessage; sdkSessionId = result.session_id; costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined; if (result.subtype !== "success") { error = `result_${result.subtype}`; } break; } } } onStream?.({ type: "finish" }); return { status: error !== undefined ? "failed" : "completed", text: fullText, usage, ...(costUsd !== undefined ? { costUsd } : {}), numTurns, sdkSessionId, ...(error !== undefined ? { error } : {}), }; } catch (e) { const aborted = req.abortController?.signal.aborted === true; return { status: aborted ? "interrupted" : "failed", text: fullText, usage, ...(costUsd !== undefined ? { costUsd } : {}), numTurns, sdkSessionId, ...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }), }; } } async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise { if (content === "") return; try { await req.prisma.agentMessage.create({ data: { sessionId: req.sessionId, runId: req.runId, role, content, attachments: [], }, }); } catch { // Best-effort projection: a history write failure must not break the run. } } function extractToolResultText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const part of content) { if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") { parts.push(part.text); } } return parts.join("\n"); }