Repository navigation
Hook commands read KCAP_AGENT_ID and KCAP_DAEMON_URL past their own seams #836
Description
What happens
The daemon exports two variables into every agent it hosts: KCAP_AGENT_ID and, when it has one,
KCAP_DAEMON_URL. Together they answer one question on the hook side — am I running inside a
daemon-hosted agent, and where is my bridge? DaemonInputWaitRelay states the pairing directly:
Only a daemon-spawned agent has both
KCAP_AGENT_IDand a loopbackKCAP_DAEMON_URL
Both already have a seam that owns the rule for reading them, and most callers walk past it.
HookAgentId.FromEnvironment() maps an empty value to null and is pinned for it by
HookAgentIdTests. Three sites use it. Twelve others, spread across all ten hook commands, read
Environment.GetEnvironmentVariable("KCAP_AGENT_ID") directly and test the result with is { } or
is not null — both of which only check for null. CodexHookCommand does it both ways, 300 lines
apart.
DaemonBridgeUrl.TryParseLoopback owns the loopback rule for KCAP_DAEMON_URL. Four CLI sites read
the variable raw and each applies its own guard before calling it.
The two defects this produces
An empty KCAP_DAEMON_URL denies every Codex permission request.
CodexHookCommand.HandlePermissionRequest branches on daemonUrl is null. An empty string is not
null, so it takes the bridge path, fails TryParseLoopback inside it, and fail-closes to deny +
nonzero exit. The intended behaviour for a value that names no bridge is the stub path, where Codex
falls back to its own in-CLI approval prompt. The other two readers check emptiness first and are
unaffected.
An empty KCAP_AGENT_ID is forwarded as an agent id. The twelve raw sites set
agent_host_id: "" on the payload; the three canonical readers correctly treat the same value as
absent. The server sees a hosted-agent attribution that no agent claims.
Shape of the fix
One primitive resolving both variables once, applying both rules. The bridge cannot collapse to a
plain string?: absent and rejected as non-loopback mean different things to different callers,
and the difference is deliberate — Codex fail-closes on a rejected URL, Claude's
PermissionRequestCommand warns and falls back to the server route, DaemonInputWaitRelay gives up
silently. The primitive reports what it found; each caller keeps its own policy.
Agent id and bridge stay independently optional, because the daemon sets KCAP_AGENT_ID
unconditionally and KCAP_DAEMON_URL only when a bridge exists.
Out of scope: the rest of the KCAP_DAEMON_* family. KCAP_DAEMON_ID and KCAP_DAEMON_EPOCH are
never read from our own environment — OrphanReaper and ProcessReaper read them out of another
process's environ via ProcessIdentity.ReadAgentEnv. KCAP_DAEMON_SUPERVISED, KCAP_DAEMON_NAME
and KCAP_DAEMON_LOG_LEVEL are daemon-side and already read at its composition root.
Continues the vendor-resolution work in #810 / #823, which moved kcap's own vendor-path variables
behind HarnessRegistry.
Activity
linear-code commentedon Sep 9, 2026
AI-2620
Refuse a blank daemon bridge rather than ignoring it (#836)
Pin the value that marks a rendered session (#836)