From 8b2de25e4d8e65c7d8b6e4d0f3022a6fb15b1930 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:46:35 +0000 Subject: [PATCH] perf(runtime): send Claude Code lifecycle hooks over loopback HTTP Every Claude lifecycle hook ran the helper through Electron-as-Node, about 105 ms per call, and PostToolUse fires after every tool. Claude Code 2.1.281 can POST hooks itself (type "http"), so the gateway now also listens on 127.0.0.1 (random port) and Claude's UserPromptSubmit, PermissionRequest, PostToolUse, Stop, StopFailure, SessionEnd and Notification hooks go there. Measured with the real CLI, each hook costs about 1.4 ms instead of 105 ms. - Claude does not send SessionStart over HTTP, so that hook keeps the helper. - PreToolUse (base protection and plugin decisions) also stays on permission-gate.mjs and the 0600 socket. An HTTP hook that fails in any way lets the tool run: refused, 5xx, 401, timeout or a malformed answer. So do the sandbox proxy (auto profile), HTTP_PROXY, allowedHttpHookUrls and httpHookAllowedEnvVars. A loopback port is also reachable by other local users. - Claude fills the session id and capability headers from the session's environment (allowedEnvVars), so the token never enters the --settings argv. - The listener takes only POST requests with application/json, a loopback Host and no Origin, Referer or Sec-Fetch-*. Bodies are bounded to 512 KB like the helper's, and the request is checked against the same lease and token. Revoking a session revokes its capability. - ClaudeHttpHookPolicy keeps the helper on Windows, in plugin environments, for the auto profile, for Claude versions older than 2.1.281 or not yet known, when a proxy is set, and when inline, managed, user or project settings enable the sandbox, restrict hook URLs or headers, or set a proxy. A request whose capability header arrives empty switches HTTP off for later launches. - Plugins may no longer set allowedHttpHookUrls or httpHookAllowedEnvVars in their Claude settings. - Lifecycle acks (socket and HTTP) go out before the app reacts. onSignal runs on setImmediate in arrival order, and a throw there no longer reaches the hook. --- src/agent-runtime/runtime-protocol.d.mts | 6 + src/agent-runtime/runtime-protocol.mjs | 12 + src/main/index.ts | 7 +- src/main/services/TerminalManager.ts | 15 +- .../agent-runtime/AgentRuntimeBridge.ts | 26 +- .../services/agent-runtime/ClaudeHttpHooks.ts | 204 ++++++++++ .../agent-runtime/ProviderRuntimeLaunch.ts | 57 ++- .../services/agent-runtime/RuntimeGateway.ts | 255 +++++++++++- src/main/services/agent-runtime/index.ts | 1 + src/main/services/terminalLaunch.ts | 4 +- tests/claude-http-hooks-real.test.mjs | 122 ++++++ tests/claude-http-hooks.test.mjs | 363 ++++++++++++++++++ tests/fixtures/mock-anthropic-api.mjs | 60 +++ 13 files changed, 1110 insertions(+), 22 deletions(-) create mode 100644 src/main/services/agent-runtime/ClaudeHttpHooks.ts create mode 100644 tests/claude-http-hooks-real.test.mjs create mode 100644 tests/claude-http-hooks.test.mjs create mode 100644 tests/fixtures/mock-anthropic-api.mjs diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index 84659d0a..42562e1a 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -31,3 +31,9 @@ export const MIN_DECIDE_TIMEOUT_MS: number; export const MAX_DECIDE_TIMEOUT_MS: number; export function permissionGateTimings(budgetMs?: number): { budgetMs: number; gatewayMs: number; helperMs: number; hookSeconds: number }; export function helperDeadlineMs(env: Record | undefined): number; +export const CLAUDE_HTTP_HOOK: Readonly<{ + pathPrefix: string; + sessionHeader: string; + capabilityHeader: string; + minimumVersion: string; +}>; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 114cdf2c..7fb7c247 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -74,3 +74,15 @@ export function helperDeadlineMs(env) { const raw = env?.[DECISION_BUDGET_ENV]; return permissionGateTimings(typeof raw === "string" && /^\d{1,6}$/.test(raw) ? Number(raw) : undefined).helperMs; } + +// Claude Code's own HTTP hooks (`type: "http"`, measured with 2.1.281) carry the lifecycle events straight to the +// gateway's loopback listener: no process per event. Claude fills both headers from the session's environment +// (`allowedEnvVars`), so the capability never appears in its argv or in a file. Decision hooks (PreToolUse) stay on +// permission-gate.mjs and the 0600 socket: every failure of an HTTP hook lets the tool run (fail open). +export const CLAUDE_HTTP_HOOK = Object.freeze({ + pathPrefix: "/claude/v1/", + sessionHeader: "x-canvastty-session", + capabilityHeader: "x-canvastty-capability", + // The oldest Claude Code whose HTTP hooks, header interpolation and loopback rule were checked end to end. + minimumVersion: "2.1.281" +}); diff --git a/src/main/index.ts b/src/main/index.ts index 452bb13b..70e2fdc0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,7 @@ import { import type { StdioHelperLaunch } from "./services/agent-browser/ProviderLaunch"; import { AgentRuntimeBridge, + ClaudeHttpHookPolicy, RuntimeGateway } from "./services/agent-runtime"; import type { RuntimeHookHelperLaunch } from "./services/agent-runtime/ProviderRuntimeLaunch"; @@ -420,7 +421,9 @@ async function initializeServices(): Promise { } }, onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId), - onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal) + onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal), + // Claude Code's lifecycle hooks go straight to a loopback listener where ClaudeHttpHookPolicy allows it. + httpHooks: true }); await runtimeGateway.start(); const runtimeHelperPath = app.isPackaged @@ -440,6 +443,7 @@ async function initializeServices(): Promise { args: [runtimeHelperPath], env: { ELECTRON_RUN_AS_NODE: "1" } }; + const claudeHttpHookPolicy = new ClaudeHttpHookPolicy(); agentRuntimeBridge = new AgentRuntimeBridge(runtimeGateway, { helper: agentRuntimeHelper, runtimeDirectory: lifecycleRuntimeDirectory, @@ -451,6 +455,7 @@ async function initializeServices(): Promise { permissionGate: { command: process.execPath, args: [permissionGatePath], env: { ELECTRON_RUN_AS_NODE: "1" } }, wantsDecisions: (provider) => decisionHooks.wanted(provider), decisionBudgetMs: (provider) => decisionHooks.budgetMs(provider), + claudeHttpHooks: (facts) => claudeHttpHookPolicy.verdict(facts), pluginHooks: { runner: { command: process.execPath, diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index d36f8256..1a4f603a 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1161,14 +1161,25 @@ export class TerminalManager { role: SessionRole, answerCaptureGrantExpiresAt: number | undefined, contribution: LaunchContribution | null, - trustedFolder?: string + trustedFolder?: string, + environmentWrapped = false ): PlannedSpawn | { failure: UnavailableProviderCli } { const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); if (providerCli?.state === "unavailable") return { failure: providerCli }; + // What decides whether Claude's lifecycle hooks may go over HTTP (ClaudeHttpHooks.ts): where and how it runs. + const claudeHttp = provider === "claude" && providerCli?.state === "available" ? { + executable: providerCli.executable, + profile, + environmentWrapped, + env: { ...terminalEnvironment(), ...providerCli.environment, ...(contribution?.env ?? {}) }, + args: contribution?.args ?? [], + cwd + } : undefined; const agentRuntime = provider === "terminal" ? null : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, ...(captureResult ? { captureResult: true } : {}), + ...(claudeHttp ? { claudeHttp } : {}), ...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null; let pluginTools: string[] = []; try { @@ -1425,7 +1436,7 @@ export class TerminalManager { let planned: PlannedSpawn | { failure: UnavailableProviderCli }; try { planned = this.planSpawn(id, metadata.provider, metadata.profile, metadata.cwd, resume, - session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder); + session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment)); } catch (error) { dropContribution(); metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 753ba4ca..2b346891 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -10,6 +10,7 @@ import { ProviderRuntimeLaunchAdapters, type ProviderRuntimeLaunchOptions } from "./ProviderRuntimeLaunch.ts"; +import type { ClaudeHttpLaunchFacts, ClaudeHttpVerdict } from "./ClaudeHttpHooks.ts"; export interface PrepareAgentRuntimeLaunchInput { terminalSessionId: string; @@ -20,6 +21,8 @@ export interface PrepareAgentRuntimeLaunchInput { answerCaptureGrantExpiresAt?: number; /** Install the decision hook (base protection and plugin decisions); by default `wantsDecisions` says. */ decisions?: boolean; + /** Claude Code: what decides whether its lifecycle hooks may go over HTTP (see ClaudeHttpHooks.ts). */ + claudeHttp?: ClaudeHttpLaunchFacts; } export interface PreparedAgentRuntimePtyLaunch { @@ -27,6 +30,8 @@ export interface PreparedAgentRuntimePtyLaunch { environment: Record; /** The decision hook was installed (the provider has one and the gateway runs). */ decisions?: boolean; + /** Claude's lifecycle hooks go over HTTP to the gateway (otherwise through the command helper). */ + httpHooks?: boolean; cleanup(): void; } @@ -42,6 +47,8 @@ export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions wantsDecisions?(provider: Exclude): boolean; /** The longest decision budget for this agent (ms); the session's gate deadlines are sized from it. */ decisionBudgetMs?(provider: Exclude): number; + /** Whether a Claude launch may use HTTP lifecycle hooks; without it every launch uses the command helper. */ + claudeHttpHooks?(facts: ClaudeHttpLaunchFacts): ClaudeHttpVerdict; } export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { @@ -52,11 +59,13 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { private coreHooksEnabled: boolean; private readonly wantsDecisions: AgentRuntimeBridgeOptions["wantsDecisions"]; private readonly decisionBudgetMs: AgentRuntimeBridgeOptions["decisionBudgetMs"]; + private readonly claudeHttpHooks: AgentRuntimeBridgeOptions["claudeHttpHooks"]; constructor(gateway: RuntimeGateway, options: AgentRuntimeBridgeOptions) { this.gateway = gateway; this.wantsDecisions = options.wantsDecisions; this.decisionBudgetMs = options.decisionBudgetMs; + this.claudeHttpHooks = options.claudeHttpHooks; this.providers = new ProviderRuntimeLaunchAdapters(options); this.coreHooksEnabled = options.coreHooksEnabled !== false; if (options.recoverOnStart) this.providers.recoverConfigurations(); @@ -78,9 +87,11 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { budgetMs ) : null; + const httpHookBase = capability && this.coreHooksEnabled ? this.claudeHttpHookBase(input) : null; let prepared; try { - prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs); + prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs, + httpHookBase ?? undefined); } catch (error) { if (capability) this.gateway.revokeTerminalSession(input.terminalSessionId); throw error; @@ -90,6 +101,7 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { return { args: prepared.args, decisions, + httpHooks: httpHookBase !== null, environment: { ...prepared.environment, ...(input.captureResult ? { [CAPTURE_RESULT_ENV]: "1" } : {}), @@ -117,6 +129,18 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { }; } + /** The gateway's HTTP hook URL when this Claude launch may use it; null keeps the command helper. */ + private claudeHttpHookBase(input: PrepareAgentRuntimeLaunchInput): string | null { + if (input.provider !== "claude" || !input.claudeHttp || !this.claudeHttpHooks) return null; + const base = this.gateway.httpHookBase; + if (!base) return null; + try { + return this.claudeHttpHooks(input.claudeHttp).ok ? base : null; + } catch { + return null; + } + } + currentStatus(terminalSessionId: string): RuntimeLifecycleState | null { return this.coreHooksEnabled ? this.gateway.currentStatus(terminalSessionId) : null; } diff --git a/src/main/services/agent-runtime/ClaudeHttpHooks.ts b/src/main/services/agent-runtime/ClaudeHttpHooks.ts new file mode 100644 index 00000000..cf3d01e2 --- /dev/null +++ b/src/main/services/agent-runtime/ClaudeHttpHooks.ts @@ -0,0 +1,204 @@ +import { execFile } from "node:child_process"; +import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { CLAUDE_HTTP_HOOK } from "../../../agent-runtime/runtime-protocol.mjs"; + +/** + * When Claude Code's lifecycle hooks may go over HTTP to the gateway's loopback listener instead of through the + * command helper. Every way an HTTP hook fails lets Claude go on (measured with 2.1.281): a refused connection, an + * error status, a timeout, an unreadable answer. For lifecycle events that costs only the card's status, but some + * settings make the hooks fail on every call, so a launch uses HTTP only when none of them applies: + * + * - Claude's own sandbox (the "auto" profile turns it on) sends HTTP hooks through its proxy, which answers 403; + * - `HTTP_PROXY` and friends route them through that proxy; + * - `allowedHttpHookUrls` blocks them, and `httpHookAllowedEnvVars` empties the headers that carry the capability; + * - a plugin environment (container, remote host) has another 127.0.0.1 and none of CanvasTTY's variables; + * - Windows keeps its current-user named pipe; older Claude versions are untested. + * + * Otherwise the command helper runs exactly as before. + */ +export interface ClaudeHttpLaunchFacts { + /** The Claude executable this launch runs. */ + executable: string; + profile: string; + /** A plugin environment wraps the launch (container, remote host). */ + environmentWrapped: boolean; + /** The launch's environment as Claude will see it. */ + env: Readonly>; + /** Claude's arguments (inline `--settings` values are read). */ + args: readonly string[]; + cwd: string; +} + +export type ClaudeHttpVerdict = { ok: true } | { ok: false; reason: string }; + +export interface ClaudeHttpHookPolicyOptions { + platform?: NodeJS.Platform; + home?: string; + /** Claude Code's managed settings files for this platform (tests replace them). */ + managedSettingsPaths?: readonly string[]; + readText?: (path: string) => string | null; + version?: (executable: string) => string | null; +} + +const PROXY_ENV = /^(?:https?|all)_proxy$/iu; +const MAX_SETTINGS_BYTES = 256 * 1024; +const MAX_PROJECT_DEPTH = 32; + +export class ClaudeHttpHookPolicy { + private readonly platform: NodeJS.Platform; + private readonly home: string; + private readonly managedSettingsPaths: readonly string[]; + private readonly readText: (path: string) => string | null; + private readonly version: (executable: string) => string | null; + + constructor(options: ClaudeHttpHookPolicyOptions = {}) { + this.platform = options.platform ?? process.platform; + this.home = options.home ?? homedir(); + this.managedSettingsPaths = options.managedSettingsPaths ?? managedSettingsFiles(this.platform); + this.readText = options.readText ?? readSmallText; + const versions = new ClaudeVersions(); + this.version = options.version ?? ((executable) => versions.get(executable)); + } + + verdict(facts: ClaudeHttpLaunchFacts): ClaudeHttpVerdict { + if (this.platform === "win32") return { ok: false, reason: "Windows keeps the named-pipe helper" }; + if (facts.environmentWrapped) return { ok: false, reason: "a plugin environment runs the agent" }; + if (facts.profile === "auto") return { ok: false, reason: "Claude's sandbox (auto profile) proxies HTTP hooks" }; + const version = this.version(facts.executable); + if (!version || compareVersions(version, CLAUDE_HTTP_HOOK.minimumVersion) < 0) { + return { ok: false, reason: version ? `Claude ${version} is older than ${CLAUDE_HTTP_HOOK.minimumVersion}` : "Claude's version is not known yet" }; + } + const proxy = Object.keys(facts.env).find((key) => PROXY_ENV.test(key) && Boolean(facts.env[key])); + if (proxy) return { ok: false, reason: `${proxy} would route HTTP hooks through a proxy` }; + for (const settings of this.settingsSources(facts)) { + const reason = blockingSetting(settings); + if (reason) return { ok: false, reason }; + } + return { ok: true }; + } + + /** Every settings object Claude reads for this launch that CanvasTTY can see: inline, managed, user, project. */ + private *settingsSources(facts: ClaudeHttpLaunchFacts): Generator { + for (let index = 0; index < facts.args.length; index++) { + const argument = facts.args[index]!; + const value = argument === "--settings" ? facts.args[index + 1] : argument.startsWith("--settings=") ? argument.slice(11) : undefined; + if (value === undefined) continue; + // A settings file argument is read like the files below. + yield value.trimStart().startsWith("{") ? parseJson(value) : parseJson(this.readText(value)); + } + for (const path of this.managedSettingsPaths) yield parseJson(this.readText(path)); + const configDir = facts.env.CLAUDE_CONFIG_DIR || join(this.home, ".claude"); + yield parseJson(this.readText(join(configDir, "settings.json"))); + let folder = facts.cwd; + for (let depth = 0; depth < MAX_PROJECT_DEPTH; depth++) { + yield parseJson(this.readText(join(folder, ".claude", "settings.json"))); + yield parseJson(this.readText(join(folder, ".claude", "settings.local.json"))); + if (this.readText(join(folder, ".git")) !== null || isDirectory(join(folder, ".git"))) break; + const parent = dirname(folder); + if (parent === folder) break; + folder = parent; + } + } +} + +/** Why these settings stop Claude's HTTP hooks from reaching the gateway, or null. */ +export function blockingSetting(value: unknown): string | null { + if (!isRecord(value)) return null; + if (isRecord(value.sandbox) && value.sandbox.enabled === true) return "Claude's sandbox is enabled in its settings"; + if (value.allowedHttpHookUrls !== undefined) return "Claude's settings restrict HTTP hook URLs"; + if (value.httpHookAllowedEnvVars !== undefined) return "Claude's settings restrict HTTP hook headers"; + if (isRecord(value.env)) { + const proxy = Object.keys(value.env).find((key) => PROXY_ENV.test(key)); + if (proxy) return `Claude's settings set ${proxy}`; + } + return null; +} + +/** `a` against `b` as dotted numbers: negative, zero or positive. */ +export function compareVersions(a: string, b: string): number { + const left = a.split(".").map((part) => Number.parseInt(part, 10)); + const right = b.split(".").map((part) => Number.parseInt(part, 10)); + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const difference = (Number.isFinite(left[index]) ? left[index]! : 0) - (Number.isFinite(right[index]) ? right[index]! : 0); + if (difference !== 0) return difference; + } + return 0; +} + +const VERSION_RE = /^(\d{1,4}\.\d{1,4}\.\d{1,6})(?:[-+][\w.]+)?$/u; + +/** + * Claude's version per executable. The native installer's layout names it (`…/claude/versions/2.1.281`), so most + * launches know it at once; otherwise `claude --version` runs once in the background and the launches before its + * answer keep the helper. + */ +export class ClaudeVersions { + private readonly known = new Map(); + private readonly pending = new Set(); + + get(executable: string): string | null { + let real: string; + let key: string; + try { + real = realpathSync(executable); + const info = statSync(real); + key = `${real}\0${info.size}\0${info.mtimeMs}`; + } catch { + return null; + } + const cached = this.known.get(key); + if (cached !== undefined) return cached; + const fromPath = VERSION_RE.exec(basename(real)); + if (fromPath && basename(dirname(real)) === "versions") { + this.known.set(key, fromPath[1]!); + return fromPath[1]!; + } + if (!this.pending.has(key)) { + this.pending.add(key); + execFile(real, ["--version"], { timeout: 10_000, maxBuffer: 16 * 1024, windowsHide: true }, (error, stdout) => { + this.pending.delete(key); + const version = error ? null : /(\d{1,4}\.\d{1,4}\.\d{1,6})/u.exec(String(stdout))?.[1] ?? null; + this.known.set(key, version); + }); + } + return null; + } +} + +function managedSettingsFiles(platform: NodeJS.Platform): string[] { + const root = platform === "darwin" ? "/Library/Application Support/ClaudeCode" + : platform === "win32" ? "C:\\Program Files\\ClaudeCode" + : "/etc/claude-code"; + const files = [join(root, "managed-settings.json")]; + try { + for (const name of readdirSync(join(root, "managed-settings.d")).sort()) { + if (name.endsWith(".json")) files.push(join(root, "managed-settings.d", name)); + } + } catch { /* no drop-in folder */ } + return files; +} + +function readSmallText(path: string): string | null { + try { + const info = statSync(path); + if (!info.isFile() || info.size > MAX_SETTINGS_BYTES) return null; + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +function isDirectory(path: string): boolean { + try { return statSync(path).isDirectory(); } catch { return false; } +} + +function parseJson(text: string | null | undefined): unknown { + if (!text) return null; + try { return JSON.parse(text); } catch { return null; } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index cc1f1953..98c58045 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -16,7 +16,13 @@ import { dirname, isAbsolute, join, win32 } from "node:path"; import { pathToFileURL } from "node:url"; import { parseDocument } from "yaml"; import type { PluginAgentHookEvent, ProviderId } from "../../../shared/contracts.ts"; -import { DECISION_BUDGET_ENV, OPENCODE_DECISIONS_ENV, permissionGateTimings } from "../../../agent-runtime/runtime-protocol.mjs"; +import { + AGENT_RUNTIME_ENV, + CLAUDE_HTTP_HOOK, + DECISION_BUDGET_ENV, + OPENCODE_DECISIONS_ENV, + permissionGateTimings +} from "../../../agent-runtime/runtime-protocol.mjs"; const FILE_MODE = 0o600; const DIRECTORY_MODE = 0o700; @@ -47,6 +53,8 @@ interface ProviderHookCommand { command: string; matcher?: string; timeout: number; + /** Claude Code only: POST the hook input to this URL instead of running `command`. */ + url?: string; } export interface RuntimePluginHookRegistration { @@ -156,13 +164,16 @@ export class ProviderRuntimeLaunchAdapters { * `decisions` adds the decision hook: PreToolUse for Claude Code, Codex and Qwen Code, the CanvasTTY plugin's * guard for OpenCode. Without it the arguments are exactly what they were before decision hooks existed. * `decisionBudgetMs` (a decision service's `decide.timeoutMs`) lengthens the hook's deadlines to fit it. + * `claudeHttpHookBase` (Claude Code only) sends its lifecycle events, except SessionStart, as HTTP hooks to the + * gateway's loopback listener instead of running the helper; the decision hook stays a command. */ prepare( provider: AgentProvider, terminalSessionId: string, coreHooksEnabled = true, decisions = false, - decisionBudgetMs?: number + decisionBudgetMs?: number, + claudeHttpHookBase?: string ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; const gate = decisions && this.decisionsSupported(provider); @@ -187,7 +198,7 @@ export class ProviderRuntimeLaunchAdapters { return prepared([], environment); } if (provider === "claude") { - return prepared(claudeHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands), environment); + return prepared(claudeHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands, claudeHttpHookBase), environment); } if (provider === "codex") { return prepared(codexHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands), environment); @@ -556,18 +567,50 @@ function claudeHookArgs( helper: RuntimeHookHelperLaunch, platform: NodeJS.Platform, coreHooksEnabled: boolean, - pluginCommands: readonly ProviderHookCommand[] + pluginCommands: readonly ProviderHookCommand[], + httpHookBase?: string ): string[] { validateHelper(helper); + const base = httpHookBase === undefined ? null : claudeHttpHookBase(httpHookBase); return ["--settings", JSON.stringify({ ...(coreHooksEnabled ? { showStatusInTerminalTab: true } : {}), hooks: groupProviderHookCommands([ - ...(coreHooksEnabled ? lifecycleCommands(CLAUDE_HOOKS, helper, platform) : []), + ...(coreHooksEnabled ? lifecycleCommands(CLAUDE_HOOKS, helper, platform).map((command, index) => ( + base && CLAUDE_HOOKS[index]!.event !== "SessionStart" + ? { ...command, url: `${base}${CLAUDE_HTTP_HOOK.pathPrefix}${CLAUDE_HOOKS[index]!.state}/${CLAUDE_HOOKS[index]!.event}` } + : command + )) : []), ...pluginCommands ]) })]; } +/** Only this machine's loopback listener: `http://127.0.0.1:`. */ +function claudeHttpHookBase(value: string): string { + const match = /^http:\/\/127\.0\.0\.1:(\d{1,5})$/u.exec(value); + const port = match ? Number(match[1]) : 0; + if (!match || port < 1 || port > 65_535) throw new Error("Claude HTTP hook base must be a loopback URL with a port."); + return value; +} + +/** + * Claude Code (2.1.281) interpolates header values only from the variables its hook lists in `allowedEnvVars`, and + * takes them from the session's environment: the capability reaches the gateway without ever being written into the + * `--settings` argument, which any local user can read in the process list. + */ +function claudeHttpHook(url: string, timeout: number): Record { + return { + type: "http", + url, + timeout, + headers: { + [CLAUDE_HTTP_HOOK.sessionHeader]: `\${${AGENT_RUNTIME_ENV.terminalSessionId}}`, + [CLAUDE_HTTP_HOOK.capabilityHeader]: `\${${AGENT_RUNTIME_ENV.capabilityToken}}` + }, + allowedEnvVars: [AGENT_RUNTIME_ENV.terminalSessionId, AGENT_RUNTIME_ENV.capabilityToken] + }; +} + export function codexLifecycleArgs( helper: RuntimeHookHelperLaunch, platform: NodeJS.Platform = process.platform @@ -1010,7 +1053,7 @@ function groupProviderHookCommands(commands: readonly ProviderHookCommand[]): Re for (const [event, entries] of Object.entries(mappings)) { grouped[event] = entries.map((mapping) => ({ ...(mapping.matcher ? { matcher: mapping.matcher } : {}), - hooks: [{ + hooks: [mapping.url ? claudeHttpHook(mapping.url, mapping.timeout) : { type: "command", command: mapping.command, timeout: mapping.timeout @@ -1026,7 +1069,7 @@ function groupProviderHookMappings( const grouped: Record = {}; const seen = new Set(); for (const mapping of commands) { - const identity = `${mapping.event}\0${mapping.matcher ?? ""}\0${mapping.command}\0${mapping.timeout}`; + const identity = `${mapping.event}\0${mapping.matcher ?? ""}\0${mapping.url ?? mapping.command}\0${mapping.timeout}`; if (seen.has(identity)) continue; seen.add(identity); (grouped[mapping.event] ??= []).push(mapping); diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 8b2b7463..feb410b1 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -1,12 +1,16 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { chmod, mkdir, rmdir, unlink } from "node:fs/promises"; +import { createServer as createHttpServer } from "node:http"; +import type { IncomingMessage, Server as HttpServer, ServerResponse } from "node:http"; import { createServer } from "node:net"; -import type { Server } from "node:net"; +import type { AddressInfo, Server } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ProviderId } from "../../../shared/contracts.ts"; import { + CLAUDE_HTTP_HOOK, MAX_ANSWER_CHARS, + MAX_HOOK_INPUT_BYTES, MAX_RUNTIME_MESSAGE_BYTES, MAX_RESULT_CHARS, normalizeThreadId, @@ -28,6 +32,14 @@ const MAX_RUNTIME_SESSIONS = 32; /** Decision checks in flight, per session and in total; over a cap the call is refused with advice to slow down. */ const MAX_DECISIONS_PER_SESSION = 8; const MAX_DECISIONS_TOTAL = 32; +/** Loopback HTTP listener for Claude Code's HTTP lifecycle hooks: connections, header and time bounds. */ +const HTTP_MAX_CONNECTIONS = 64; +const HTTP_MAX_HEADER_BYTES = 8 * 1024; +const HTTP_MAX_HEADERS = 32; +const HTTP_HEADERS_TIMEOUT_MS = 5_000; +const HTTP_REQUEST_TIMEOUT_MS = 10_000; +const HTTP_KEEP_ALIVE_MS = 5_000; +const HTTP_EVENT_RE = /^[A-Za-z][A-Za-z_]{0,79}$/u; const OVERLOADED_MESSAGE = "CanvasTTY is checking too many tool calls from this session at once. Wait a few seconds and run the command again, one at a time."; export type RuntimeLifecycleState = "idle" | "working" | "needs_approval"; @@ -110,6 +122,11 @@ export interface RuntimeGatewayOptions { */ onPermissionRequest?(terminalSessionId: string, request: RuntimePermissionRequest, signal: AbortSignal): Promise | RuntimePermissionDecision; now?: () => number; + /** + * Also listen on 127.0.0.1 (random port) for Claude Code's HTTP lifecycle hooks. POSIX only; when the listener + * cannot start, launches simply keep the command helper. + */ + httpHooks?: boolean; } export class RuntimeGateway { @@ -128,6 +145,11 @@ export class RuntimeGateway { private windowsTransport: WindowsPipeHostTransport | null = null; private endpoint: string | null = null; private ownedRuntimeDirectory: string | null = null; + private readonly httpHooksRequested: boolean; + private httpServer: HttpServer | null = null; + private httpPort: number | null = null; + /** Set when Claude reached the listener without its capability (a settings policy emptied the header). */ + private httpUnusable = false; constructor(options: RuntimeGatewayOptions = {}) { this.platform = options.platform ?? process.platform; @@ -139,6 +161,15 @@ export class RuntimeGateway { this.onAnswerCaptureRevoked = options.onAnswerCaptureRevoked; this.onPermissionRequest = options.onPermissionRequest; this.now = options.now ?? Date.now; + this.httpHooksRequested = options.httpHooks === true && this.platform !== "win32"; + } + + /** + * Base URL for Claude Code HTTP lifecycle hooks, or null when the listener is not running or proved unusable in + * this run (then launches use the command helper). + */ + get httpHookBase(): string | null { + return this.httpServer && this.httpPort !== null && !this.httpUnusable ? `http://127.0.0.1:${this.httpPort}` : null; } get address(): string { @@ -171,6 +202,7 @@ export class RuntimeGateway { try { await listen(server, created.endpoint); await chmod(created.endpoint, 0o600); + if (this.httpHooksRequested) await this.startHttp(); return created.endpoint; } catch (error) { await closeServer(server); @@ -249,6 +281,13 @@ export class RuntimeGateway { } } this.leases.clear(); + const httpServer = this.httpServer; + this.httpServer = null; + this.httpPort = null; + if (httpServer) { + httpServer.closeAllConnections(); + await closeServer(httpServer as unknown as Server); + } const server = this.server; const transport = this.windowsTransport; const endpoint = this.endpoint; @@ -295,8 +334,10 @@ export class RuntimeGateway { answerCapture })}\n`, "utf8")); } else { - this.handleLifecycle(value); + // The ack goes out before the app reacts: the hook (and the agent behind it) waits only for the check. + const delivery = this.handleLifecycle(value); socket.write(Buffer.from(`${JSON.stringify({ v: RUNTIME_PROTOCOL_VERSION, type: "ack" })}\n`, "utf8")); + this.deliverLater(delivery); } const timeout = setTimeout(close, 1_000); timeout.unref(); @@ -334,15 +375,19 @@ export class RuntimeGateway { return true; } - private handleLifecycle(value: unknown): void { + /** + * Checks one lifecycle message and updates the lease at once (so currentStatus never lags); returns the app's + * reaction to run after the hook has its answer, or null when there is nothing to report. + */ + private handleLifecycle(value: unknown): Delivery | null { const message = parseLifecycleMessage(value); const lease = this.leases.get(message.terminalSessionId); if (!lease || lease.provider !== message.provider) throw new Error("Runtime capability is invalid."); - const supplied = digest(message.capabilityToken); - const valid = supplied.length === lease.tokenDigest.length - && timingSafeEqual(supplied, lease.tokenDigest); - supplied.fill(0); - if (!valid) throw new Error("Runtime capability is invalid."); + if (!tokenMatches(lease, message.capabilityToken)) throw new Error("Runtime capability is invalid."); + return this.applyLifecycle(lease, message); + } + + private applyLifecycle(lease: RuntimeLease, message: Omit): Delivery | null { if (message.result && !lease.captureResult) throw new Error("Result capture is not enabled for this session."); if (message.lastAssistantMessage !== undefined && ( lease.answerCaptureGrantExpiresAt === null @@ -360,7 +405,7 @@ export class RuntimeGateway { && lease.activeTurnId && message.turnId !== lease.activeTurnId ) { - return; + return null; } const signal: RuntimeLifecycleSignal = { state: message.state, @@ -380,7 +425,124 @@ export class RuntimeGateway { turnId: signal.turnId, ...(signal.threadId === undefined ? {} : { threadId: signal.threadId }) }; - this.onSignal?.(message.terminalSessionId, signal); + return { terminalSessionId: message.terminalSessionId, signal }; + } + + /** Runs the app's reaction after the current I/O callback, in arrival order; a failure there never reaches a hook. */ + private deliverLater(delivery: Delivery | null): void { + const onSignal = this.onSignal; + if (!delivery || !onSignal) return; + setImmediate(() => { + try { + onSignal(delivery.terminalSessionId, delivery.signal); + } catch (error) { + console.warn("CanvasTTY could not apply an agent lifecycle event:", error instanceof Error ? error.message : String(error)); + } + }); + } + + private async startHttp(): Promise { + const server = createHttpServer({ + maxHeaderSize: HTTP_MAX_HEADER_BYTES, + headersTimeout: HTTP_HEADERS_TIMEOUT_MS, + requestTimeout: HTTP_REQUEST_TIMEOUT_MS, + keepAliveTimeout: HTTP_KEEP_ALIVE_MS + }, (request, response) => this.acceptHttp(request, response)); + server.maxHeadersCount = HTTP_MAX_HEADERS; + server.maxConnections = HTTP_MAX_CONNECTIONS; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => { + server.off("error", reject); + resolve(); + }); + }); + } catch (error) { + console.warn("CanvasTTY runs Claude Code lifecycle hooks through its helper: the loopback listener did not start.", + error instanceof Error ? error.message : String(error)); + server.close(); + return; + } + server.on("error", () => undefined); + this.httpServer = server; + this.httpPort = (server.address() as AddressInfo).port; + } + + /** + * One Claude Code HTTP lifecycle hook: `POST /claude/v1//` with the session id and capability in + * headers Claude fills from the session's environment. Anything a browser could send (another Origin, a form + * content type, a rebound Host) is refused before the body is read. The answer is always `{}`: lifecycle hooks + * decide nothing, and Claude goes on whatever the status. + */ + private acceptHttp(request: IncomingMessage, response: ServerResponse): void { + const finish = (status: number, closeConnection = status !== 200): void => { + if (response.headersSent) return; + response.writeHead(status, { + "content-type": "application/json", + "cache-control": "no-store", + ...(closeConnection ? { connection: "close" } : {}) + }); + response.end("{}"); + }; + const route = httpRoute(request, this.httpPort); + if (typeof route === "number") return finish(route); + const headerValue = (name: string): string | null => { + const value = request.headers[name]; + return typeof value === "string" ? value : null; + }; + const terminalSessionId = headerValue(CLAUDE_HTTP_HOOK.sessionHeader); + const capability = headerValue(CLAUDE_HTTP_HOOK.capabilityHeader); + const lease = terminalSessionId && terminalSessionId.length <= 160 ? this.leases.get(terminalSessionId) : undefined; + if (!lease || lease.provider !== "claude") return finish(401); + if (!capability) { + // Claude sent the session but not its capability: a settings policy (httpHookAllowedEnvVars) emptied the + // header. New launches go back to the helper for the rest of this run. + this.httpUnusable = true; + return finish(401); + } + if (capability.length < 32 || !tokenMatches(lease, capability)) return finish(401); + const declared = Number(request.headers["content-length"]); + let size = 0; + let oversized = Number.isFinite(declared) && declared > MAX_HOOK_INPUT_BYTES; + const chunks: Buffer[] = []; + const complete = (): void => { + if (response.headersSent) return; + let input: unknown = null; + if (!oversized) { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + input = raw.trim().length > 0 ? JSON.parse(raw) : null; + } catch { + input = null; + } + } + let delivery: Delivery | null = null; + try { + delivery = this.leases.get(terminalSessionId!) === lease + ? this.applyLifecycle(lease, claudeLifecycleMessage(terminalSessionId!, route.state, route.event, input, lease.captureResult)) + : null; + } catch { + delivery = null; + } + finish(200, oversized); + this.deliverLater(delivery); + }; + if (oversized) return complete(); + request.on("data", (chunk: Buffer) => { + if (oversized) return; + size += chunk.length; + if (size > MAX_HOOK_INPUT_BYTES) { + // Like the helper: an input over the bound still reports its state, without any of its fields. + oversized = true; + chunks.length = 0; + complete(); + return; + } + chunks.push(chunk); + }); + request.on("end", complete); + request.on("error", () => undefined); } /** @@ -448,6 +610,79 @@ export class RuntimeGateway { } } +interface Delivery { + terminalSessionId: string; + signal: RuntimeLifecycleSignal; +} + +function tokenMatches(lease: RuntimeLease, token: string): boolean { + const supplied = digest(token); + const valid = supplied.length === lease.tokenDigest.length && timingSafeEqual(supplied, lease.tokenDigest); + supplied.fill(0); + return valid; +} + +/** + * The route of a Claude HTTP hook request, or the status that refuses it. Only a JSON POST addressed to this + * listener's own loopback Host passes, and only without the headers a browser adds (Origin, Referer, Sec-Fetch-*). + */ +function httpRoute(request: IncomingMessage, port: number | null): { state: RuntimeLifecycleState; event: string } | number { + if (request.method !== "POST") return 405; + if (port === null || request.headers.host !== `127.0.0.1:${port}`) return 403; + if (request.headers.origin !== undefined || request.headers.referer !== undefined + || request.headers["sec-fetch-site"] !== undefined || request.headers["sec-fetch-mode"] !== undefined) return 403; + const type = request.headers["content-type"]; + if (typeof type !== "string" || type.split(";", 1)[0]!.trim().toLowerCase() !== "application/json") return 415; + const path = request.url ?? ""; + if (!path.startsWith(CLAUDE_HTTP_HOOK.pathPrefix)) return 404; + const parts = path.slice(CLAUDE_HTTP_HOOK.pathPrefix.length).split("/"); + if (parts.length !== 2 || !(RUNTIME_STATES as readonly string[]).includes(parts[0]!) || !HTTP_EVENT_RE.test(parts[1]!)) return 404; + return { state: parts[0] as RuntimeLifecycleState, event: parts[1]! }; +} + +/** What hook-helper.mjs would have sent for this Claude hook input (same fields, same bounds). */ +function claudeLifecycleMessage( + terminalSessionId: string, + state: RuntimeLifecycleState, + event: string, + input: unknown, + captureResult: boolean +): Omit { + const record = isRecord(input) ? input : {}; + const turnId = firstString(record.turn_id, record.turnId, record.prompt_id, record.promptId); + const threadId = normalizeThreadId("claude", firstString( + record.session_id, record.sessionId, record.thread_id, record.threadId, record.conversation_id, record.conversationId + )); + const finalAnswer = state === "idle" && event === "Stop" && typeof record.last_assistant_message === "string" + ? record.last_assistant_message + : null; + let result: { text: string; truncated: boolean } | undefined; + if (captureResult && finalAnswer !== null) { + const text = boundedText(finalAnswer, MAX_RESULT_CHARS); + result = { text, truncated: text.length < finalAnswer.length }; + } + return { + terminalSessionId, + provider: "claude", + state, + event, + turnId: turnId !== null && turnId.length <= 160 ? turnId : null, + ...(threadId !== undefined ? { threadId } : {}), + ...(result === undefined ? {} : { result }) + }; +} + +function firstString(...values: unknown[]): string | null { + const found = values.find((value) => typeof value === "string" && value.length > 0); + return typeof found === "string" ? found : null; +} + +/** Cuts at the limit without leaving a dangling high surrogate. */ +function boundedText(value: string, limit: number): string { + const text = value.slice(0, limit); + return /[\uD800-\uDBFF]$/u.test(text) ? text.slice(0, -1) : text; +} + /** What leaves the gateway, whatever the handler said: never an allow of cut input. */ function enforceDecision(request: RuntimePermissionRequest, decision: RuntimePermissionDecision): RuntimePermissionDecision { if (!decision || !["allow", "deny", "ask", "none"].includes(decision.behavior)) return { behavior: "ask" }; diff --git a/src/main/services/agent-runtime/index.ts b/src/main/services/agent-runtime/index.ts index dbc5d716..6c25ac46 100644 --- a/src/main/services/agent-runtime/index.ts +++ b/src/main/services/agent-runtime/index.ts @@ -1,2 +1,3 @@ export * from "./AgentRuntimeBridge.ts"; export * from "./RuntimeGateway.ts"; +export * from "./ClaudeHttpHooks.ts"; diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 7ad6e94c..8e0543d4 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -313,7 +313,9 @@ const CORE_OWNED_SUBCOMMANDS: Partial> = { }; /** Claude settings keys that decide approvals, the hooks or the sandbox; a plugin's settings may carry e.g. `env` only. */ -const CLAUDE_CORE_SETTINGS = ["permissions", "hooks", "disableAllHooks", "sandbox", "defaultMode", "apiKeyHelper"]; +// `allowedHttpHookUrls` and `httpHookAllowedEnvVars` would silently switch off CanvasTTY's HTTP lifecycle hooks. +const CLAUDE_CORE_SETTINGS = ["permissions", "hooks", "disableAllHooks", "sandbox", "defaultMode", "apiKeyHelper", + "allowedHttpHookUrls", "httpHookAllowedEnvVars"]; // Claude 2.1.281 --help: `--bare` and `--safe-mode` skip hooks; `--allowedTools` approves tools without asking; // `--permission-prompt-tool` / `--permission-prompts` decide who answers permission prompts. const CLAUDE_CORE_OWNED_FLAGS = new Set(["--bare", "--safe-mode", "--allowedTools", "--allowed-tools", "--permission-prompt-tool", "--permission-prompts"]); diff --git a/tests/claude-http-hooks-real.test.mjs b/tests/claude-http-hooks-real.test.mjs new file mode 100644 index 00000000..e6a2ea58 --- /dev/null +++ b/tests/claude-http-hooks-real.test.mjs @@ -0,0 +1,122 @@ +// End to end with the real Claude Code CLI, when it is installed and new enough: its HTTP lifecycle hooks reach the +// gateway, SessionStart and the decision hook still run as commands, and base-protection-style denies still hold. +// Claude runs against a local mock of the Messages API, under a throwaway HOME, with a dummy key. +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import test from "node:test"; + +import { CLAUDE_HTTP_HOOK } from "../src/agent-runtime/runtime-protocol.mjs"; +import { AgentRuntimeBridge } from "../src/main/services/agent-runtime/AgentRuntimeBridge.ts"; +import { compareVersions } from "../src/main/services/agent-runtime/ClaudeHttpHooks.ts"; +import { RuntimeGateway } from "../src/main/services/agent-runtime/RuntimeGateway.ts"; +import { startMockAnthropicApi } from "./fixtures/mock-anthropic-api.mjs"; + +function findClaude() { + for (const candidate of (process.env.PATH ?? "").split(delimiter).map((folder) => join(folder, "claude"))) { + if (!candidate || !existsSync(candidate)) continue; + try { + const version = /(\d+\.\d+\.\d+)/u.exec(execFileSync(candidate, ["--version"], { encoding: "utf8", timeout: 20_000, env: { PATH: process.env.PATH, HOME: tmpdir() } }))?.[1]; + if (version) return { path: candidate, version }; + } catch { /* not runnable */ } + } + return null; +} + +const claude = process.platform === "win32" ? null : findClaude(); +const skip = !claude ? "Claude Code CLI not installed" + : compareVersions(claude.version, CLAUDE_HTTP_HOOK.minimumVersion) < 0 ? `Claude ${claude.version} is older than ${CLAUDE_HTTP_HOOK.minimumVersion}` + : false; + +test("real Claude Code: lifecycle over HTTP, SessionStart and decisions through the helper, denies still hold", { skip, timeout: 120_000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), "canvastty-real-claude-")); + t.after(() => rm(root, { recursive: true, force: true })); + const home = join(root, "home"); + const work = join(root, "work"); + await mkdir(join(home, ".claude"), { recursive: true }); + await mkdir(work); + + const signals = []; + const decisions = []; + const gateway = new RuntimeGateway({ + runtimeDirectory: join(root, "rt"), + httpHooks: true, + onSignal: (id, signal) => signals.push({ id, ...signal }), + onPermissionRequest: (_id, request) => { + decisions.push(request.toolInput?.command); + return String(request.toolInput?.command).includes("forbidden") + ? { behavior: "deny", message: "blocked by the test gate" } + : { behavior: "none" }; + } + }); + await gateway.start(); + t.after(() => gateway.close()); + const node = { command: process.execPath, args: [] }; + const bridge = new AgentRuntimeBridge(gateway, { + helper: { ...node, args: [new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url).pathname] }, + permissionGate: { ...node, args: [new URL("../src/agent-runtime/permission-gate.mjs", import.meta.url).pathname] }, + runtimeDirectory: join(root, "rt"), + openCodePluginPath: join(root, "opencode.mjs"), + claudeHttpHooks: () => ({ ok: true }) + }); + const launch = bridge.prepareLaunch({ + terminalSessionId: "real-claude", provider: "claude", cwd: work, captureResult: true, decisions: true, + claudeHttp: { executable: claude.path, profile: "default", environmentWrapped: false, env: {}, args: [], cwd: work } + }); + t.after(() => launch.cleanup()); + assert.equal(launch.httpHooks, true); + assert.equal(launch.decisions, true); + + const api = await startMockAnthropicApi([`echo one > ${join(work, "allowed.txt")}`, `echo forbidden > ${join(work, "denied.txt")}`]); + t.after(() => api.close()); + const debugFile = join(root, "claude-debug.log"); + const child = spawn(claude.path, [ + "-p", "run the steps", "--allowedTools", "Bash", "--model", "claude-sonnet-4-5", "--debug-file", debugFile, ...launch.args + ], { + cwd: work, + env: { + PATH: process.env.PATH, + HOME: home, + TMPDIR: `${root}/`, + CLAUDE_CONFIG_DIR: join(home, ".claude"), + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, ".local", "share"), + XDG_STATE_HOME: join(home, ".local", "state"), + ANTHROPIC_BASE_URL: api.url, + ANTHROPIC_API_KEY: "placeholder", + DISABLE_AUTOUPDATER: "1", + DISABLE_TELEMETRY: "1", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + ...launch.environment + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const code = await new Promise((resolve) => child.on("close", resolve)); + assert.equal(code, 0, stderr); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(existsSync(join(work, "allowed.txt")), true, "the allowed step ran"); + assert.equal(existsSync(join(work, "denied.txt")), false, "the denied step did not run"); + assert.equal(decisions.length, 2, "both Bash calls went through the decision hook"); + + const debug = readFileSync(debugFile, "utf8"); + const base = gateway.httpHookBase; + for (const route of ["working/UserPromptSubmit", "working/PostToolUse", "idle/Stop", "idle/SessionEnd"]) { + assert.ok(debug.includes(`HTTP hook POST to ${base}${CLAUDE_HTTP_HOOK.pathPrefix}${route}`), route); + } + assert.equal(debug.includes(`${base}${CLAUDE_HTTP_HOOK.pathPrefix}idle/SessionStart`), false); + assert.equal(debug.includes(launch.environment.CANVASTTY_RUNTIME_CAPABILITY), false, "the capability is never logged"); + + const events = signals.map((signal) => signal.event); + assert.equal(events[0], "SessionStart"); + for (const event of ["UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"]) assert.ok(events.includes(event), event); + const stop = signals.find((signal) => signal.event === "Stop"); + assert.deepEqual(stop.result, { text: "DONE", truncated: false }); + assert.match(stop.threadId, /^[0-9a-f-]{36}$/u); + assert.ok(stop.turnId); +}); diff --git a/tests/claude-http-hooks.test.mjs b/tests/claude-http-hooks.test.mjs new file mode 100644 index 00000000..079f17dd --- /dev/null +++ b/tests/claude-http-hooks.test.mjs @@ -0,0 +1,363 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { AGENT_RUNTIME_ENV, CAPTURE_RESULT_ENV, CLAUDE_HTTP_HOOK } from "../src/agent-runtime/runtime-protocol.mjs"; +import { AgentRuntimeBridge } from "../src/main/services/agent-runtime/AgentRuntimeBridge.ts"; +import { + ClaudeHttpHookPolicy, + ClaudeVersions, + blockingSetting, + compareVersions +} from "../src/main/services/agent-runtime/ClaudeHttpHooks.ts"; +import { ProviderRuntimeLaunchAdapters } from "../src/main/services/agent-runtime/ProviderRuntimeLaunch.ts"; +import { RuntimeGateway } from "../src/main/services/agent-runtime/RuntimeGateway.ts"; +import { claudeCoreSettingsKey } from "../src/main/services/terminalLaunch.ts"; + +const POSIX = { skip: process.platform === "win32" ? "the loopback listener is POSIX-only" : false }; +const helperPath = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url).pathname; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "canvastty-http-hooks-")); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +async function startGateway(t, options = {}) { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ + runtimeDirectory: root, + httpHooks: true, + onSignal: (id, signal) => signals.push({ id, signal }), + ...options + }); + await gateway.start(); + t.after(() => gateway.close()); + return { gateway, signals, root }; +} + +/** A raw POST with exactly these headers (fetch would add Sec-Fetch-Mode). */ +function post(base, path, { headers = {}, body = "{}", method = "POST" } = {}) { + const url = new URL(path, base); + return new Promise((resolve, reject) => { + const request = httpRequest({ host: url.hostname, port: url.port, path: url.pathname, method, headers: { host: url.host, ...headers } }, (response) => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { text += chunk; }); + response.on("end", () => resolve({ status: response.statusCode, body: text })); + }); + request.on("error", reject); + request.end(body); + }); +} + +function hookHeaders(capability, extra = {}) { + return { + "content-type": "application/json", + [CLAUDE_HTTP_HOOK.sessionHeader]: capability.terminalSessionId, + [CLAUDE_HTTP_HOOK.capabilityHeader]: capability.capabilityToken, + ...extra + }; +} + +function runHelper(capability, state, event, input, env = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [helperPath, state, event], { + env: { + ...process.env, + ...env, + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: capability.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: capability.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }, + stdio: ["pipe", "ignore", "ignore"] + }); + child.stdin.end(input); + child.on("close", resolve); + }); +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test("an HTTP lifecycle hook reports exactly what the command helper reports for the same input", POSIX, async (t) => { + const { gateway, signals } = await startGateway(t); + const base = gateway.httpHookBase; + assert.match(base, /^http:\/\/127\.0\.0\.1:\d+$/u); + const inputs = [ + ["working", "UserPromptSubmit", { prompt: "stays local", prompt_id: "turn-1", session_id: "5F1C2A90-AA11-4B22-9C33-0D44E55F6677" }], + ["working", "PostToolUse", { prompt_id: "turn-1", tool_name: "Bash", tool_input: { command: "ls" }, tool_response: { stdout: "secret" } }], + ["idle", "Stop", { prompt_id: "turn-1", session_id: "5f1c2a90-aa11-4b22-9c33-0d44e55f6677", last_assistant_message: `${"a".repeat(4095)}😀tail` }], + ["idle", "Stop", { prompt_id: "x".repeat(161), session_id: "not-a-uuid", last_assistant_message: "short" }], + ["needs_approval", "Notification", "not json at all"] + ]; + for (const captureResult of [false, true]) { + for (const [state, event, input] of inputs) { + const raw = typeof input === "string" ? input : JSON.stringify(input); + const viaHelper = gateway.registerSession("helper-session", "claude", captureResult); + await runHelper(viaHelper, state, event, raw, captureResult ? { [CAPTURE_RESULT_ENV]: "1" } : {}); + const viaHttp = gateway.registerSession("http-session", "claude", captureResult); + const response = await post(base, `${CLAUDE_HTTP_HOOK.pathPrefix}${state}/${event}`, { headers: hookHeaders(viaHttp), body: raw }); + assert.deepEqual(response, { status: 200, body: "{}" }); + await flush(); + const helperSignal = signals.filter((entry) => entry.id === "helper-session").at(-1)?.signal; + const httpSignal = signals.filter((entry) => entry.id === "http-session").at(-1)?.signal; + assert.ok(helperSignal, `${event} reached the gateway through the helper`); + assert.deepEqual(httpSignal, helperSignal, `${state}/${event} capture=${captureResult}`); + signals.length = 0; + } + } + assert.equal(JSON.stringify(signals).includes("stays local"), false); +}); + +test("the listener refuses browsers, other hosts, other routes and anyone without the session's capability", POSIX, async (t) => { + const { gateway, signals } = await startGateway(t); + const base = gateway.httpHookBase; + const capability = gateway.registerSession("claude-one", "claude"); + const path = `${CLAUDE_HTTP_HOOK.pathPrefix}working/UserPromptSubmit`; + const cases = [ + ["GET", { method: "GET" }, 405], + ["preflight", { method: "OPTIONS" }, 405], + ["Origin", { headers: hookHeaders(capability, { origin: "https://evil.example" }) }, 403], + ["Referer", { headers: hookHeaders(capability, { referer: "https://evil.example/" }) }, 403], + ["Sec-Fetch-Site", { headers: hookHeaders(capability, { "sec-fetch-site": "cross-site" }) }, 403], + ["Sec-Fetch-Mode", { headers: hookHeaders(capability, { "sec-fetch-mode": "no-cors" }) }, 403], + ["rebound Host", { headers: hookHeaders(capability, { host: "evil.example" }) }, 403], + ["localhost Host", { headers: hookHeaders(capability, { host: `localhost:${new URL(base).port}` }) }, 403], + ["form body", { headers: hookHeaders(capability, { "content-type": "text/plain" }) }, 415], + ["no content type", { headers: { [CLAUDE_HTTP_HOOK.sessionHeader]: capability.terminalSessionId, [CLAUDE_HTTP_HOOK.capabilityHeader]: capability.capabilityToken } }, 415], + ["unknown session", { headers: hookHeaders({ ...capability, terminalSessionId: "nobody" }) }, 401], + ["wrong capability", { headers: hookHeaders({ ...capability, capabilityToken: "x".repeat(43) }) }, 401] + ]; + for (const [name, options, status] of cases) { + const response = await post(base, path, options); + assert.equal(response.status, status, name); + } + for (const route of ["/claude/v1/working", "/claude/v1/busy/UserPromptSubmit", "/claude/v1/working/Bad-Event", "/claude/v1/working/X/Y", "/other"]) { + assert.equal((await post(base, route, { headers: hookHeaders(capability) })).status, 404, route); + } + // Another provider's lease never takes Claude's HTTP hooks. + const codex = gateway.registerSession("codex-one", "codex"); + assert.equal((await post(base, path, { headers: hookHeaders(codex) })).status, 401); + await flush(); + assert.deepEqual(signals, []); + assert.equal(gateway.httpHookBase, base); + + // Revoking the session revokes its capability at once. + assert.equal((await post(base, path, { headers: hookHeaders(capability), body: '{"prompt_id":"t"}' })).status, 200); + gateway.revokeTerminalSession("claude-one"); + assert.equal((await post(base, path, { headers: hookHeaders(capability) })).status, 401); +}); + +test("a known session without its capability marks HTTP unusable for later launches", POSIX, async (t) => { + const { gateway } = await startGateway(t); + const base = gateway.httpHookBase; + const capability = gateway.registerSession("claude-one", "claude"); + const response = await post(base, `${CLAUDE_HTTP_HOOK.pathPrefix}working/PostToolUse`, { + headers: hookHeaders({ ...capability, capabilityToken: "" }) + }); + assert.equal(response.status, 401); + assert.equal(gateway.httpHookBase, null); +}); + +test("an input over 512 KB still reports its state, without any of its fields", POSIX, async (t) => { + const { gateway, signals } = await startGateway(t); + const capability = gateway.registerSession("claude-one", "claude", true); + const body = JSON.stringify({ prompt_id: "turn-big", last_assistant_message: "x".repeat(600 * 1024) }); + const response = await post(gateway.httpHookBase, `${CLAUDE_HTTP_HOOK.pathPrefix}idle/Stop`, { headers: hookHeaders(capability), body }); + assert.equal(response.status, 200); + await flush(); + assert.deepEqual(signals, [{ id: "claude-one", signal: { state: "idle", event: "Stop", turnId: null } }]); +}); + +test("hooks get their answer before the app reacts (HTTP and socket)", POSIX, async (t) => { + let busyMs = 0; + const { gateway } = await startGateway(t, { + onSignal: () => { + const until = Date.now() + busyMs; + while (Date.now() < until) { /* a slow reaction in main */ } + } + }); + busyMs = 400; + const capability = gateway.registerSession("claude-one", "claude"); + // The client runs in its own process, so the gateway's busy loop cannot hide when the answer left. + const script = ` + const http = require("node:http"); + const started = performance.now(); + const request = http.request({ host: "127.0.0.1", port: ${new URL(gateway.httpHookBase).port}, path: "${CLAUDE_HTTP_HOOK.pathPrefix}working/PostToolUse", method: "POST", + headers: ${JSON.stringify(hookHeaders(capability))} }, (response) => { response.resume(); response.on("end", () => { console.log(Math.round(performance.now() - started)); }); }); + request.end("{}");`; + const child = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "inherit"] }); + let out = ""; + child.stdout.on("data", (chunk) => { out += chunk; }); + await new Promise((resolve) => child.on("close", resolve)); + assert.ok(Number(out) < 300, `HTTP answer took ${out} ms while the reaction took 400 ms`); + + const socketCapability = gateway.registerSession("claude-two", "claude"); + const line = JSON.stringify({ v: 1, type: "lifecycle", terminalSessionId: socketCapability.terminalSessionId, provider: "claude", + capabilityToken: socketCapability.capabilityToken, state: "working", event: "PostToolUse", turnId: null }); + const socketScript = ` + const net = require("node:net"); + const started = performance.now(); + const socket = net.createConnection(${JSON.stringify(socketCapability.address)}, () => socket.write(${JSON.stringify(line + "\n")})); + socket.on("data", () => { console.log(Math.round(performance.now() - started)); socket.destroy(); });`; + const socketChild = spawn(process.execPath, ["-e", socketScript], { stdio: ["ignore", "pipe", "inherit"] }); + let socketOut = ""; + socketChild.stdout.on("data", (chunk) => { socketOut += chunk; }); + await new Promise((resolve) => socketChild.on("close", resolve)); + assert.ok(Number(socketOut) < 300, `socket ack took ${socketOut} ms while the reaction took 400 ms`); +}); + +test("a failing reaction never reaches the hook", POSIX, async (t) => { + const { gateway } = await startGateway(t, { onSignal: () => { throw new Error("boom"); } }); + const warn = t.mock.method(console, "warn", () => undefined); + const capability = gateway.registerSession("claude-one", "claude"); + const response = await post(gateway.httpHookBase, `${CLAUDE_HTTP_HOOK.pathPrefix}working/PostToolUse`, { headers: hookHeaders(capability) }); + assert.equal(response.status, 200); + await flush(); + assert.equal(warn.mock.callCount(), 1); +}); + +test("without httpHooks, or on Windows, there is no listener", async (t) => { + const root = await fixture(t); + const gateway = new RuntimeGateway({ runtimeDirectory: root }); + if (process.platform !== "win32") { + await gateway.start(); + t.after(() => gateway.close()); + } + assert.equal(gateway.httpHookBase, null); + const windows = new RuntimeGateway({ platform: "win32", httpHooks: true }); + assert.equal(windows.httpHookBase, null); +}); + +const helper = Object.freeze({ command: "/opt/CanvasTTY/electron", args: ["/opt/CanvasTTY/agent-runtime/hook-helper.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }); +const gate = Object.freeze({ command: "/opt/CanvasTTY/electron", args: ["/opt/CanvasTTY/agent-runtime/permission-gate.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }); + +test("Claude's lifecycle hooks become HTTP hooks except SessionStart; the decision hook stays a command", async (t) => { + const root = await fixture(t); + const adapters = new ProviderRuntimeLaunchAdapters({ + helper, permissionGate: gate, runtimeDirectory: root, openCodePluginPath: join(root, "opencode.mjs"), platform: "darwin" + }); + const prepared = adapters.prepare("claude", "term-1", true, true, undefined, "http://127.0.0.1:43210"); + const settings = JSON.parse(prepared.args[1]); + assert.equal(settings.hooks.SessionStart[0].hooks[0].type, "command"); + assert.match(settings.hooks.SessionStart[0].hooks[0].command, /hook-helper\.mjs' 'idle' 'SessionStart'/u); + assert.equal(settings.hooks.PreToolUse[0].hooks[0].type, "command"); + assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /permission-gate\.mjs' 'pretool'/u); + for (const [event, state] of [["UserPromptSubmit", "working"], ["PermissionRequest", "needs_approval"], ["PostToolUse", "working"], + ["Stop", "idle"], ["StopFailure", "idle"], ["SessionEnd", "idle"], ["Notification", "needs_approval"]]) { + const hook = settings.hooks[event][0].hooks[0]; + assert.deepEqual(hook, { + type: "http", + url: `http://127.0.0.1:43210/claude/v1/${state}/${event}`, + timeout: 3, + headers: { + "x-canvastty-session": "${CANVASTTY_RUNTIME_TERMINAL_SESSION_ID}", + "x-canvastty-capability": "${CANVASTTY_RUNTIME_CAPABILITY}" + }, + allowedEnvVars: ["CANVASTTY_RUNTIME_TERMINAL_SESSION_ID", "CANVASTTY_RUNTIME_CAPABILITY"] + }, event); + } + assert.equal(settings.hooks.Notification[0].matcher, "permission_prompt"); + assert.throws(() => adapters.prepare("claude", "term-1", true, false, undefined, "http://evil.example:80"), /loopback/u); + // Without a base the launch is byte-for-byte the helper one. + assert.deepEqual(adapters.prepare("claude", "term-1", true, false).args, adapters.prepare("claude", "term-1", true, false, undefined, undefined).args); + assert.equal(adapters.prepare("claude", "term-1", true, false).args[1].includes('"type":"http"'), false); +}); + +test("the bridge uses HTTP only for Claude, only when the policy allows and the listener runs", POSIX, async (t) => { + const { gateway, root } = await startGateway(t); + const verdicts = []; + let allow = true; + const bridge = new AgentRuntimeBridge(gateway, { + helper, permissionGate: gate, runtimeDirectory: root, openCodePluginPath: join(root, "opencode.mjs"), + claudeHttpHooks: (facts) => { verdicts.push(facts); return allow ? { ok: true } : { ok: false, reason: "no" }; } + }); + const facts = { executable: "/bin/claude", profile: "default", environmentWrapped: false, env: {}, args: [], cwd: root }; + const launched = bridge.prepareLaunch({ terminalSessionId: "c1", provider: "claude", cwd: root, claudeHttp: facts }); + assert.equal(launched.httpHooks, true); + assert.ok(launched.args[1].includes(`${gateway.httpHookBase}/claude/v1/idle/Stop`)); + assert.equal(launched.args.join(" ").includes(launched.environment[AGENT_RUNTIME_ENV.capabilityToken]), false); + launched.cleanup(); + allow = false; + const refused = bridge.prepareLaunch({ terminalSessionId: "c2", provider: "claude", cwd: root, claudeHttp: facts }); + assert.equal(refused.httpHooks, false); + assert.equal(refused.args[1].includes('"type":"http"'), false); + refused.cleanup(); + allow = true; + const codex = bridge.prepareLaunch({ terminalSessionId: "x1", provider: "codex", cwd: root, claudeHttp: facts }); + assert.equal(codex.httpHooks, false); + codex.cleanup(); + assert.equal(verdicts.length, 2); +}); + +test("the policy keeps the helper wherever an HTTP hook could not reach the gateway", () => { + const files = new Map(); + const policy = (options = {}) => new ClaudeHttpHookPolicy({ + platform: "darwin", home: "/profile", managedSettingsPaths: ["/managed/managed-settings.json"], + readText: (path) => files.get(path) ?? null, version: () => "2.1.281", ...options + }); + const facts = { executable: "/bin/claude", profile: "default", environmentWrapped: false, env: { PATH: "/bin" }, args: [], cwd: "/work/repo/sub" }; + assert.deepEqual(policy().verdict(facts), { ok: true }); + const refused = (value, options) => { + const verdict = policy(options).verdict({ ...facts, ...value }); + assert.equal(verdict.ok, false, JSON.stringify(value)); + return verdict.reason; + }; + assert.match(refused({}, { platform: "win32" }), /Windows/u); + assert.match(refused({ environmentWrapped: true }), /environment/u); + assert.match(refused({ profile: "auto" }), /sandbox/u); + assert.match(refused({}, { version: () => "2.1.280" }), /older/u); + assert.match(refused({}, { version: () => null }), /not known/u); + assert.equal(policy({ version: () => "2.2.0" }).verdict(facts).ok, true); + for (const name of ["HTTP_PROXY", "https_proxy", "ALL_PROXY"]) assert.match(refused({ env: { [name]: "http://proxy:3128" } }), /proxy/u); + assert.equal(policy().verdict({ ...facts, env: { HTTP_PROXY: "" } }).ok, true); + assert.match(refused({ args: ["--settings", JSON.stringify({ sandbox: { enabled: true } })] }), /sandbox/u); + assert.match(refused({ args: [`--settings=${JSON.stringify({ allowedHttpHookUrls: [] })}`] }), /URLs/u); + + const withFile = (path, value) => { + files.clear(); + files.set(path, JSON.stringify(value)); + }; + withFile("/managed/managed-settings.json", { httpHookAllowedEnvVars: ["X"] }); + assert.match(refused({}), /headers/u); + withFile("/profile/.claude/settings.json", { env: { HTTPS_PROXY: "http://proxy" } }); + assert.match(refused({}), /HTTPS_PROXY/u); + withFile("/custom/settings.json", { sandbox: { enabled: true } }); + assert.match(refused({ env: { CLAUDE_CONFIG_DIR: "/custom" } }), /sandbox/u); + withFile("/work/repo/.claude/settings.local.json", { allowedHttpHookUrls: ["https://x/*"] }); + assert.match(refused({}), /URLs/u); + // The project walk stops at the repository root. + files.set("/work/repo/sub/.git", "gitdir: /elsewhere"); + assert.equal(policy().verdict(facts).ok, true); + files.clear(); + withFile("/work/repo/.claude/settings.json", { sandbox: { enabled: false }, env: { FOO: "1" } }); + assert.equal(policy().verdict(facts).ok, true); + + assert.equal(blockingSetting(null), null); + assert.equal(compareVersions("2.1.281", "2.1.281"), 0); + assert.ok(compareVersions("2.1.300", "2.1.281") > 0); + assert.ok(compareVersions("2.10.0", "2.9.9") > 0); + assert.ok(compareVersions("1.0", "2.1.281") < 0); +}); + +test("Claude's version comes from the native installer's layout without running it", async (t) => { + const root = await fixture(t); + await mkdir(join(root, "versions")); + const executable = join(root, "versions", "2.1.281"); + await writeFile(executable, "#!/bin/sh\nexit 3\n", { mode: 0o755 }); + const versions = new ClaudeVersions(); + assert.equal(versions.get(executable), "2.1.281"); + assert.equal(versions.get(join(root, "missing")), null); +}); + +test("a plugin's Claude settings may not restrict HTTP hooks", () => { + assert.equal(claudeCoreSettingsKey({ allowedHttpHookUrls: [] }), "allowedHttpHookUrls"); + assert.equal(claudeCoreSettingsKey({ httpHookAllowedEnvVars: [] }), "httpHookAllowedEnvVars"); + assert.equal(claudeCoreSettingsKey({ env: { A: "1" } }), null); +}); diff --git a/tests/fixtures/mock-anthropic-api.mjs b/tests/fixtures/mock-anthropic-api.mjs new file mode 100644 index 00000000..b74373fa --- /dev/null +++ b/tests/fixtures/mock-anthropic-api.mjs @@ -0,0 +1,60 @@ +// A deterministic stand-in for the Anthropic Messages API (streaming), for tests that drive the real Claude Code CLI +// without an account: while a request offers the Bash tool it answers with the next queued Bash call, then with +// "DONE". Only 127.0.0.1. +import { createServer } from "node:http"; + +export async function startMockAnthropicApi(commands) { + const queue = [...commands]; + const requests = []; + let serial = 0; + const server = createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + if (!request.url?.startsWith("/v1/messages") || request.url.includes("count_tokens")) { + response.writeHead(request.url?.includes("count_tokens") ? 200 : 404, { "content-type": "application/json" }); + response.end(request.url?.includes("count_tokens") ? '{"input_tokens":10}' : '{"type":"error","error":{"type":"not_found_error","message":"not here"}}'); + return; + } + let parsed = {}; + try { parsed = JSON.parse(body); } catch { /* answered as text */ } + requests.push(parsed); + const id = `msg_mock_${++serial}`; + const model = parsed.model ?? "claude-mock"; + const offersBash = (parsed.tools ?? []).some((tool) => tool.name === "Bash"); + const command = offersBash ? queue.shift() : undefined; + const usage = { input_tokens: 10, output_tokens: 5 }; + const start = ["message_start", { type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, usage } }]; + const events = command === undefined ? [ + start, + ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }], + ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "DONE" } }], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 5 } }], + ["message_stop", { type: "message_stop" }] + ] : [ + start, + ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: `toolu_mock_${serial}`, name: "Bash", input: {} } }], + ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ command, description: "test step" }) } }], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + ["message_delta", { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } }], + ["message_stop", { type: "message_stop" }] + ]; + if (!parsed.stream) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ id, type: "message", role: "assistant", model, content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", usage })); + return; + } + response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + for (const [event, data] of events) response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + response.end(); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + url: `http://127.0.0.1:${server.address().port}`, + requests, + close: () => new Promise((resolve) => { server.closeAllConnections(); server.close(() => resolve()); }) + }; +}