Repository navigation
feat(headroom)!: serve the headroom allowlist from a file the daemon re-reads - #381
Conversation
|
Merging this produces a major release, because |
60cd683 to
499a63d
Compare
The headroom daemon's allowlist was fixed at start through
HEADROOM_ALLOWED_BASE_URLS, so a front door that moved to another port
refused every provider launch through headroom until the daemon could
restart, which waited for every registered session to end. The supervisor
now writes the allowlist to headroom/run/allowlist.txt and starts the
daemon with --allowed-base-urls-file, which re-reads the file on its next
request after a change, so the allowlist never needs a restart.
The allowlist is exactly the direct origin of every live front door.
Headroom checks an upstream against it only when a request names one in
x-headroom-base-url, and only the front door's hop sets that header, always
to its own direct origin. Each front door records its direct port in
frontdoor/direct/<pid>.json while it serves, so a superseded door keeps its
origin admitted until it exits. The supervisor judges the records from one
bounded ps read per tick, per pid when a batch read fails, keeps any it
cannot judge, and prunes those whose pid is gone or no longer a front door,
along with malformed records and stale temporary files.
Headroom refuses the file, and then admits nothing, unless every directory
its path resolves through from / down, and the file itself, is owned by its
user or root and not writable by group or others, with no exception for a
sticky directory and only mode bits judged. agent-shim judges the same path
the same way, and holds its socket directory and the file to its stricter
owner-only rule. On every tick that fails in its own directories, the
supervisor narrows them and rewrites the file atomically with no daemon
restart. A directory above the root that fails headroom's rule, such as a
group-writable home or a shared /tmp, is not agent-shim's to change: the
supervisor stops the daemon with a lastError naming the directory, its mode
and the remedy, and headroom status and doctor report it whether or not a
daemon serves. The remedy follows headroom's own: chmod go-w for a
directory the user owns that is not sticky, otherwise moving the root. The
supervisor records the file's hash in the tick it writes
it, and a daemon's pid as soon as it is spawned. A start attempt ends when
the daemon exits, and lastError quotes the redacted end of its log. Ambient
HEADROOM_ALLOWED_BASE_URLS and _FILE are removed from the daemon's
environment.
A provider launch waits until the allowlist file, recorded as served, lists
the serving door's origin, instead of being refused while sessions hold the
daemon. A daemon started by an earlier release is recognised by its command
line, a headroom proxy on our socket without the flag, and joined only when
its recorded hash is that release's hash for the providers and door on disk;
a recorded orphan is stopped only when its command line proves it a daemon.
headroom status and doctor report the file or hash lagging the doors'
origins, an earlier release's daemon, a file headroom would refuse or that
breaks the owner-only rule, and doors the process table could not answer
for, and doctor warns rather than fails for a superseded front door still
serving its registered launches.
BREAKING CHANGE: ensureHeadroom takes requiredUpstreams in place of
requiredAllowlistHash, and launchRequiredUpstreams is exported to compute it.
EnsureHeadroomPorts gains daemonCommandLine. SupervisorPorts.spawnHeadroom
takes the allowlist file path in place of the allowlist; SupervisorPorts
gains daemonCommandLine, frontDoorLiveness, daemonLogSize and
readDaemonLogTail; SupervisorPorts.socketTrust gains readlink, and its lstat
must report a regular file as the new SocketPathStat kind "file".
RunDoctorParams.headroom.allowlist is now { drift, file, logPath,
undeterminedFrontDoors, fileProblem }, where fileProblem is
{ message, repairable }, and RunDoctorParams.frontDoor gains liveSessions.
headroom status --json replaces allowlistDrifted, allowlistDriftInputs and
frontDoorOrigin with allowlistFile, allowlistDrift, allowlistFileProblem
({ message, repairable }) and undeterminedFrontDoors. The daemon must be a
headroom build that accepts --allowed-base-urls-file; the default
headroom.source moves to commit ca7589c5 on the ExaDev headroom fork's main,
which carries it. An agent-shim root
whose ancestors fail headroom's rule, such as one under /tmp, can no longer
route through headroom.
The configuration model, architecture, testing, CLI reference and library docs describe the allowlist as the live front doors' direct origins in an owner-only file the daemon re-reads, headroom's rule for the file's whole path (symlinks walked hop by hop, the opened file compared with the one inspected, directories re-checked on every request, owners and mode bits only) and agent-shim's matching check, what the supervisor repairs and what it reports with a remedy instead, why a root under /tmp is refused, the per-door records and their pruning, the launch gate's wait, the earlier-release daemon, rolling back, the headroom status JSON fields, and the new default headroom source pin.
499a63d to
6269969
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Refs #367
Refs #372
Headroom's allowlist was fixed at daemon start through
HEADROOM_ALLOWED_BASE_URLS, and the front door's direct origin is on it, so a door port change refused every provider launch through headroom until the daemon restarted, which waited for the session registry to empty. This moves the allowlist into a file the daemon re-reads, usingheadroom proxy --allowed-base-urls-filefrom ExaDev/headroom#18.The allowlist is now exactly the direct origin of every live front door. Headroom checks an upstream against its allowlist only when a request names one in
x-headroom-base-url, and in agent-shim that header is set only by the front door's hop, fromroute.headroomUpstream, which is the door's own direct origin for the http provider route and the codex mount and undefined for the OAuth pass-through and the self-hosted Remote Control route. So provider base URLs and Claude Code's own API never need to be listed, and earlier revisions of this PR that listed provider origins (and kept a provider file's last good definition while it did not parse, and tightened provider base URL validation for it) have been reverted: a provider file edit no longer touches the allowlist at all, and provider base URLs validate as they do on main.Each front door records its direct port in
frontdoor/direct/<supervisor-pid>.jsononce its listeners are bound and removes it on exit. The headroom supervisor lists thestate.jsonorigin plus every recorded door the process table shows running as a front-door supervisor, and writes that list toheadroom/run/allowlist.txtowner-only and atomically on any tick where the file differs, recording its hash inallowlistHashin the same tick.Headroom (ExaDev/headroom#18, merged as
ca7589c5on the fork's main) refuses the file, and then admits nothing, unless every directory its path resolves through, from/down, following each symlink hop by hop wherever it meets one, is owned by the effective user or root and not writable by group or others, and the file itself is owned by the user or root and not writable by group or others. There is no exception for a sticky shared directory such as/tmp; only owners and mode bits are judged (a macOS or NFSv4 ACL is not seen; a POSIX one is, through its mask in the group bits); it checks that the file it opens is the one it inspected (device and inode); and it re-checks every directory on every request. agent-shim'sverifyAllowlistFilewalks the same path the same way (lstat and readlink from/, the same symlink hop limit) and judges every directory by that rule, and holds its own socket directory and the file to its stricter owner-only rule (a real file and directory, owned by this user, no group or other access). What fails in agent-shim's own directories (headroom/,run/, the file) the supervisor repairs on every tick it fails, with no daemon restart. A directory above the root is never changed: a group-writable home directory, a shared/tmp, or another user's directory stops the daemon at once with alastErrornaming the directory, its mode and the remedy (chmod go-w <dir>for a directory the user owns that is not sticky, otherwise moving the root, which is the same split headroom's own refusal message makes), andheadroom status(allowlistFileProblem: { message, repairable }) anddoctor(a fail) report it whether or not a daemon is serving. So anAGENT_SHIM_HOMEunder/tmpcannot route through headroom; the docs say so next to the placement note.The daemon re-reads the file on its next request, so a superseded door keeps working until it exits and a door move needs no restart. Door liveness is one bounded
psread per tick, falling back to one read per pid when the batch fails; a pid no read can answer for keeps its record and origin, and status and doctor name it, while a pid ps calls too large is gone. A records entry that cannot be read never stops the tick, and malformed records and stale temporary files are pruned. Install-source and settings drift still restart the daemon under the empty-registry rule, and a failed write of the file stops the daemon and ends the supervisor withlastError, which quotes the redacted end of the daemon's log when it exits before it is ready.ensureHeadroomkeeps the gate (a provider launch never registers against a daemon that would refuse it) but waits instead of refusing: it registers once the allowlist file, recorded as served, lists the serving door's direct origin (launchRequiredUpstreams, now exported). This replaces the drain-then-restart #372 asked for. A daemon started by an earlier release is recognised by its command line (aheadroom ... proxyon our socket without the flag, so a reused pid is never mistaken for one), and judged in one place (earlierRelease.ts) by comparing its recorded hash with that release's own allowlist formula over the providers and door on disk; the launch joins it when they match and fails at once otherwise.headroom statusanddoctorreport allowlist drift as the file or recorded hash lagging the live doors' origins, a daemon from an earlier release whose fixed allowlist cannot be shown to admit the serving door, an allowlist file headroom would refuse, and doors the process table could not answer for; doctor warns rather than fails for a superseded front door still serving launches registered against it.The history is two commits: the code and its tests, with the one breaking-change footer describing the final library surface (
ensureHeadroom'srequiredUpstreamsand the exportedlaunchRequiredUpstreams,EnsureHeadroomPorts.daemonCommandLine,SupervisorPorts.spawnHeadroomtaking the file path plusdaemonCommandLine,frontDoorLiveness,daemonLogSizeandreadDaemonLogTail,socketTrustgainingreadlinkand itslstatreportingSocketPathStat's new"file"kind,fileProblemandallowlistFileProblemas{ message, repairable }, theRunDoctorParamsheadroom and front-door shapes, and theheadroom status --jsonfields), and the docs. The daemon must be a headroom build that accepts the flag.Rolling back is safe but not seamless: the state schemas are unchanged, so nothing bricks, but while this release's supervisor keeps running, an earlier release's launcher compares the hash this release records (of the doors' origins) with its own formula, which never matches, so its provider launches through headroom are refused or wait until that supervisor idles out. Launch with
--no-headroommeanwhile, or stop the supervisor (agent-shim headroom statusnames its pid;kill <pid>stops it and its daemon).Default source:
HEADROOM_DEFAULT_SOURCEis pinned toheadroom-ai[proxy] @ git+https://github.com/ExaDev/headroom@ca7589c574bfd52919503a74f09f4d1fe5ecf106, the merge of ExaDev/headroom#18 on the fork's main, which carries--allowed-base-urls-file; the pin's comment lists that flag among the capabilities a pinned commit must carry, and every mention of the previous SHA (the configuration-model example and the source and doctor test fixtures) moved with it.The final end-to-end run used the bundled supervisor from this branch against a fresh clone of ExaDev/headroom at
ca7589c5, built withuv sync --frozen --extra proxy, with throwaway roots in the home directory and a door stand-in carrying the__frontdoor-supervisorargument. Withheadroom/andrun/pre-created 0775, the supervisor logged the repair, narrowed both to 0700, wrote the file 0600 holding only the door's origin, and startedheadroom proxy --uds ... --allowed-base-urls-file .../run/allowlist.txt; the door's origin got 200 and a provider's base URL 400. Achmod 0640of the file and achmod 0770ofrun/were each logged as a repair and narrowed back on the next tick, with requests still answered 200 and one daemon pid and one ready line throughout. With a root under a directory made 0775 inside the throwaway tree, the supervisor exited before starting a daemon withlastErrornaming that directory, mode 0775 andchmod go-w;headroom status(allowlistFileProblem) anddoctor(a fail) said the same with no daemon running. Headroom atca7589c5, run directly on that root's file, refused the door's origin (400), admitted it (200) on the next request once the directory was 0755, and refused it again once it was back to 0775, so the two checks agree and headroom re-checks per request. Through an injected trust port over the real tree (real stats, one fabricated), a 0775/Users/joewas refused withchmod go-w, a user-owned 1777 directory and a root-owned 1777 one were refused with the move-the-root remedy, and a 0755 home and a root-owned/Userswere accepted; a real root under/private/tmpwas refused over/private/tmp(mode 1777) with the move-the-root remedy. Nothing outside the throwaway roots was changed. Earlier runs covered two doors taking over and retiring with one daemon pid, a provider file edit leaving the allowlist untouched, and an earlier-release daemon probe through the realensureHeadroom.pnpm typecheck,pnpm lint,pnpm test,pnpm knipandpnpm schemapass locally, rebased on the current main.