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()); }) + }; +}