/** * 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 SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import type { PrismaClient } from "@prisma/client"; export interface ProjectContext { readonly projectId: string; readonly boundChatId: string; readonly workspaceDir: string; } export type StreamEvent = | { readonly type: "text-delta"; readonly text: string } | { readonly type: "tool-start"; readonly toolName: string } | { readonly type: "tool-end"; readonly toolName: string } | { 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 resumeSessionId?: string | undefined; readonly mcpServers?: Record | undefined; readonly maxTurns?: number; readonly runId: string; readonly sessionId: string; readonly prisma: PrismaClient; readonly onStream?: StreamCallback; } export type RunStatus = "completed" | "length" | "failed"; export interface RunResult { readonly status: RunStatus; readonly text: string; readonly usage: { inputTokens: number; outputTokens: number }; 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 numTurns = 0; let sdkSessionId: string | undefined; let error: string | undefined; try { await persistAgentMessage(req, "user", req.prompt); const options: Parameters[0]["options"] = { cwd: req.project.workspaceDir, allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"], 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.resumeSessionId !== undefined) options.resume = req.resumeSessionId; if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers; const conversation = query({ prompt: req.prompt, options, }); for await (const message of conversation) { switch (message.type) { case "stream_event": { // Partial messages — real-time text deltas + tool call chunks. 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_start" && evt.content_block.type === "tool_use") { onStream?.({ type: "tool-start", toolName: evt.content_block.name }); } if (evt.type === "content_block_stop") { // Tool end is hard to attribute from stream events alone; // the assistant message below carries tool_use blocks. } break; } case "assistant": { // Complete assistant message (one turn done). Extract text + usage. 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") { onStream?.({ type: "tool-end", toolName: block.name }); } } 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 "result": { const result = message as SDKResultMessage; sdkSessionId = result.session_id; if (result.subtype !== "success") { error = `result_${result.subtype}`; } break; } } } onStream?.({ type: "finish" }); return { status: error !== undefined ? "failed" : "completed", text: fullText, usage, numTurns, sdkSessionId, ...(error !== undefined ? { error } : {}), }; } catch (e) { return { status: "failed", text: fullText, usage, numTurns, sdkSessionId, 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. } }