Skip to content

perf(runtime): send Claude Code lifecycle hooks over loopback HTTP, keep decisions on the helper - #90

Merged
howdeploy merged 1 commit into
howdeploy:mainfrom
BIackFIame:perf/claude-http-hooks
Sep 28, 2026
Merged

howdeploy merged 1 commit into
howdeploy:mainfrom
BIackFIame:perf/claude-http-hooks

Conversation

@BIackFIame

Copy link
Copy Markdown
Contributor

Goal

Each Claude Code lifecycle hook currently starts the hook helper through Electron-as-Node, which costs about 105 ms of wall time and about 105 ms of CPU per call. PostToolUse fires after every tool the agent runs, so a turn with 40 tool calls spends more than 4 s just starting helpers.

Claude Code 2.1.281 can POST a hook to a URL by itself (type: "http"). This PR sends Claude's lifecycle hooks to a loopback listener in the gateway instead of starting a process. The decision hook (PreToolUse) deliberately stays on the helper and the 0600 socket; the reasons are in the failure semantics below.

Branch perf/claude-http-hooks, one commit on origin/main (31f287d). It merges cleanly with #89 (perf/runtime-costs, 44a5fa7). On the merged tree typecheck passes and all 1051 tests pass.

Numbers (real Claude Code 2.1.281, end to end)

Each run is claude -p against a local mock of the Messages API that asks for 40 Bash calls (true), under a throwaway HOME. The helper is the production one: the Electron binary with ELECTRON_RUN_AS_NODE=1. A cycle is the time between two consecutive model requests, so it contains the tool plus every hook around it. 3 runs × 37 cycles per row. M1 Max; the machine had a load average of 5–30 from other work.

Lifecycle hooks Decision hook cycle median cycle p95 wall time for 40 calls CPU of the Claude process tree per run
off off 17.4 ms 22.1 ms 1.06 s 1.26 s
main: helper off 123.9 ms 136.7 ms 5.77 s 6.04 s
this PR: helper (fallback path) off 123.2 ms 131.3 ms 5.73 s 5.88 s
this PR: HTTP off 19.5 ms 28.6 ms 1.28 s 1.50 s
off helper 127.6 ms 136.0 ms 5.48 s 5.72 s
main: helper helper 234.9 ms 249.9 ms 10.31 s 10.31 s
this PR: HTTP helper 132.3 ms 146.0 ms 5.79 s 6.01 s
  • Per lifecycle hook call: helper ≈ 105–106 ms of wall time (123.2 − 18.1) and ≈ 105 ms of CPU; HTTP ≈ 1.4 ms at the median and ≈ 4 ms at p95 (19.5 − 18.1 and 28.6 − 24.5), with no process at all.
  • A session with the decision hook still pays ≈ 110 ms per matched call (Bash|Write|Edit|MultiEdit|NotebookEdit). That cost is the helper process; see the native helper issue.
  • With the gateway's event loop 20 % busy in 4 ms chunks, the HTTP cycle was 20.4 / 29.0 ms (median / p95), the helper cycle 124.9 / 132.9 ms.
  • Script: bench-real.mjs (outside the repository). It uses tests/fixtures/mock-anthropic-api.mjs from this PR.

What changes

Where Change
RuntimeGateway.ts Optional loopback listener (httpHooks: true, POSIX only). POST /claude/v1/<state>/<event> is authenticated against the same lease and token as the socket. From the Claude hook input it builds exactly the message hook-helper.mjs would send: the same fields, the 4096-character result bound and the surrogate cut. The answer is always {}.
RuntimeGateway.ts (socket and HTTP) Early ack. The message is checked and lease.latest / activeTurnId are updated synchronously, then the ack is sent, then onSignal (applyProviderSignal, agentControl.onSignal, Even G2) runs on setImmediate in arrival order. Before, the hook waited for all of that work. An exception in onSignal is logged and never reaches the hook.
ProviderRuntimeLaunch.ts Given a base URL, Claude's lifecycle entries become type: "http" hooks. SessionStart stays a command because Claude skips HTTP hooks for it. PreToolUse stays permission-gate.mjs. Without a base URL the generated arguments are byte-for-byte what main produces (tested).
ClaudeHttpHooks.ts (new) ClaudeHttpHookPolicy decides per launch whether HTTP may be used; see "When HTTP is used". ClaudeVersions reads the version from the native installer's layout (…/claude/versions/2.1.281). Otherwise it runs claude --version once in the background, and launches before that answer arrives use the helper.
AgentRuntimeBridge.ts, TerminalManager.ts, index.ts planSpawn passes what the policy needs: the executable, the profile, whether an environment wraps the launch, the env, the contributed args and the cwd. The bridge asks the policy. Launch results report httpHooks.
terminalLaunch.ts A plugin's Claude settings may no longer set allowedHttpHookUrls or httpHookAllowedEnvVars. Either key would silently switch the HTTP hooks off (#88 validation).

Failure semantics (measured, Claude Code 2.1.281)

PreToolUse HTTP hook answering deny, timeout: 3, tool = echo ran > marker:

Hook outcome Tool ran?
200 + permissionDecision: deny no
200 + ask (in -p) no
200 + legacy {"decision":"block"} no
connection refused yes
HTTP 500 / 401 with a deny body yes
HTTP 302 (redirects are not followed) yes
malformed JSON / non-JSON / wrong schema yes
empty body (read as {}) yes
slower than timeout, or no answer yes, after the timeout
socket reset yes
Claude's sandbox enabled (the auto profile) → sandbox proxy answers 403 yes
HTTP_PROXY without NO_PROXY for 127.0.0.1 → sent through the proxy (CONNECT) yes, and the token goes to the proxy
allowedHttpHookUrls in inline, user or project settings yes
httpHookAllowedEnvVars set → headers arrive empty → 401 yes

For comparison, the command hook: exit 1, a crash, malformed stdout, a timeout and a missing script all let the tool run, and only exit 2 blocks. permission-gate.mjs always exits 0, so today an unreachable or broken gateway is already fail open. For failures inside CanvasTTY, HTTP is no worse. The rows from the sandbox down, however, are failures that exist only for HTTP and are driven by settings. The auto profile is one of them, and a project's .claude/settings.local.json is inside the folder the agent writes to. A loopback port can also be tied up by any local user, while the 0700/0600 socket cannot be reached by them at all. Deny-critical decisions therefore stay on the helper and the socket. Only hooks whose failure costs a card's status move to HTTP.

When HTTP is used

HTTP is used only if every condition below holds; otherwise the launch uses the helper exactly as on main:

  • not Windows (the current-user named pipe stays);
  • no plugin environment wraps the launch (a container or remote host has its own 127.0.0.1 and none of CanvasTTY's variables);
  • the profile is not auto;
  • Claude is 2.1.281 or newer;
  • no HTTP(S)_PROXY / ALL_PROXY in the launch environment;
  • none of these settings sources enables sandbox.enabled, sets allowedHttpHookUrls or httpHookAllowedEnvVars, or sets a proxy in env: the inline --settings, the managed settings (plus managed-settings.d/), $CLAUDE_CONFIG_DIR/settings.json, or the project's .claude/settings{,.local}.json from the cwd up to the repository root.

If a request arrives with a known session but an empty capability (a policy emptied the header), later launches go back to the helper for the rest of the run.

Security design of the listener

  • It binds 127.0.0.1 only, on port 0 (the OS picks the port). No IPv6, no wildcard address.
  • The session id and capability travel in x-canvastty-session and x-canvastty-capability. Claude fills them from the session's environment through allowedEnvVars, so the 256-bit token is never in the --settings argv (which every local user can see in ps) and never in a file. Interpolation was checked with and without CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1. The token is compared as sha256 digests with timingSafeEqual, and the lease must belong to claude.
  • Browser, CSRF and DNS rebinding:
    • only POST is accepted (other methods and preflights get 405);
    • Content-Type must be application/json, which a cross-site request cannot send without a preflight (415 otherwise);
    • any Origin, Referer, Sec-Fetch-Site or Sec-Fetch-Mode gets 403;
    • Host must be exactly 127.0.0.1:<port> (403 otherwise, including for localhost:<port>);
    • no CORS headers are sent.
  • Bounds:
    • bodies up to 512 KB, as for the helper; a larger input still reports its state without its fields, and the connection is closed;
    • headers up to 8 KB and at most 32 of them;
    • headersTimeout 5 s, requestTimeout 10 s, keep-alive 5 s;
    • at most 64 connections;
    • the route must be /claude/v1/<known state>/<event of up to 80 characters>.
  • Revocation: revokeTerminalSession zeroes and drops the lease, and the next request with that token gets 401 (tested). close() closes the listener and every connection.
  • What remains: any local user can tie up the port (DoS). The worst outcome is lost status updates, never lost protection.

What stays on the helper

Hook Why
Claude SessionStart Claude 2.1.281 skips HTTP hooks for SessionStart and Setup
Claude PreToolUse (base protection, plugin decisions) fail open on every HTTP failure; settings-driven failures; a loopback port is reachable by other users
Codex, Qwen Code, Kimi, Hermes, Grok (lifecycle and decisions) these CLIs only run commands
Codex answer capture (Even G2) Codex only
Plugin hooks (plugin-hook-runner.mjs) they run the plugin's own code
Claude in the auto profile, in plugin environments, on Windows, behind a proxy, with the restricting settings above, or older than 2.1.281 see "When HTTP is used"
OpenCode already in process, unchanged

Tests

  • tests/claude-http-hooks.test.mjs (12 tests):
    • parity: for the same input, the HTTP path and the real hook-helper.mjs produce identical signals, with and without result capture. Inputs include a UUID in upper case, a session id that is not a UUID, a turn id of 161 characters, an emoji cut at 4096 and a body that is not JSON;
    • every refusal: method, Origin, Referer, Sec-Fetch-*, rebound Host, localhost Host, form content type, missing content type, unknown session, wrong token, another provider's lease, routes;
    • revocation;
    • an empty capability switches HTTP off;
    • a body over 512 KB;
    • early ack on both transports: a client in another process gets its answer in < 300 ms while onSignal busy-waits 400 ms;
    • a failing onSignal;
    • generated settings (HTTP entries, SessionStart and PreToolUse commands, no token in argv, loopback-only base);
    • bridge gating;
    • every policy rule;
    • version from the installer layout;
    • the plugin settings keys.
  • tests/claude-http-hooks-real.test.mjs: skipped when claude is not installed or is older than 2.1.281. It runs the real CLI with the arguments the bridge generates, against the mock API, under a throwaway HOME. Checks:
    • UserPromptSubmit, PostToolUse, Stop and SessionEnd arrive over HTTP (Claude's debug log);
    • SessionStart arrives through the helper;
    • both Bash calls pass through the decision hook, and the call the gate denies does not run;
    • the Stop result, threadId and turnId arrive;
    • the capability never appears in Claude's debug log.
  • Hidden-app check (built out/main, hidden and unfocusable windows, keychain calls refused, throwaway HOME and userData), with a real Claude card in the default profile:
    • the card's status followed idle → working → needs_approval → working → idle from the HTTP hooks;
    • the approved echo ran;
    • base protection denied sudo -n true through the helper, and the model received the elevation message;
    • Claude's argv had the HTTP entries, a command SessionStart and a command PreToolUse, and no capability;
    • the listener answered 401 to a wrong token and 403 to an Origin;
    • the window stayed invisible and unfocused throughout.
  • Gates: npm run typecheck ✓, npm test 1026/1026 ✓, npm run build ✓, npm run audit:secrets ✓ (source and out/), npm run test:even 47/47 ✓.

Not covered

  • Windows: HTTP is not enabled there and was not tested there.
  • Claude versions before 2.1.281 were not tested and use the helper.
  • It is not checked whether Claude re-reads project settings during a session. The policy reads them at launch, and a later change can only cost lifecycle status.
  • The decision hook's own cost (≈ 110 ms per matched call) is unchanged; a native helper is proposed separately.

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.
BIackFIame added a commit to BIackFIame/CanvasTTY that referenced this pull request Sep 28, 2026
@howdeploy
howdeploy merged commit b57b9dd into howdeploy:main Sep 28, 2026
3 checks passed
howdeploy added a commit that referenced this pull request Sep 28, 2026
Integrate the reviewed performance and reliability changes from #89, #90, #92, #93, #94, and #95.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants