Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/agent-runtime/runtime-protocol.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> | undefined): number;
export const CLAUDE_HTTP_HOOK: Readonly<{
pathPrefix: string;
sessionHeader: string;
capabilityHeader: string;
minimumVersion: string;
}>;
12 changes: 12 additions & 0 deletions src/agent-runtime/runtime-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
});
7 changes: 6 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -420,7 +421,9 @@ async function initializeServices(): Promise<void> {
}
},
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
Expand All @@ -440,6 +443,7 @@ async function initializeServices(): Promise<void> {
args: [runtimeHelperPath],
env: { ELECTRON_RUN_AS_NODE: "1" }
};
const claudeHttpHookPolicy = new ClaudeHttpHookPolicy();
agentRuntimeBridge = new AgentRuntimeBridge(runtimeGateway, {
helper: agentRuntimeHelper,
runtimeDirectory: lifecycleRuntimeDirectory,
Expand All @@ -451,6 +455,7 @@ async function initializeServices(): Promise<void> {
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,
Expand Down
15 changes: 13 additions & 2 deletions src/main/services/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down
26 changes: 25 additions & 1 deletion src/main/services/agent-runtime/AgentRuntimeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ProviderRuntimeLaunchAdapters,
type ProviderRuntimeLaunchOptions
} from "./ProviderRuntimeLaunch.ts";
import type { ClaudeHttpLaunchFacts, ClaudeHttpVerdict } from "./ClaudeHttpHooks.ts";

export interface PrepareAgentRuntimeLaunchInput {
terminalSessionId: string;
Expand All @@ -20,13 +21,17 @@ 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 {
args: string[];
environment: Record<string, string>;
/** 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;
}

Expand All @@ -42,6 +47,8 @@ export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions
wantsDecisions?(provider: Exclude<ProviderId, "terminal">): boolean;
/** The longest decision budget for this agent (ms); the session's gate deadlines are sized from it. */
decisionBudgetMs?(provider: Exclude<ProviderId, "terminal">): 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 {
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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" } : {}),
Expand Down Expand Up @@ -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;
}
Expand Down
204 changes: 204 additions & 0 deletions src/main/services/agent-runtime/ClaudeHttpHooks.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>;
/** 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<unknown> {
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<string, string | null>();
private readonly pending = new Set<string>();

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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
Loading
Loading