Skip to content

Hook commands read KCAP_AGENT_ID and KCAP_DAEMON_URL past their own seams #836

Closed

Description

@Inok

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_ID and a loopback KCAP_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions