Skip to content

feat(headroom)!: serve the headroom allowlist from a file the daemon re-reads - #381

Merged
Mearman merged 2 commits into
mainfrom
feat/headroom-allowlist-file
Oct 12, 2026
Merged

Mearman merged 2 commits into
mainfrom
feat/headroom-allowlist-file

Conversation

@Mearman

@Mearman Mearman commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

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, using headroom proxy --allowed-base-urls-file from 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, from route.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>.json once its listeners are bound and removes it on exit. The headroom supervisor lists the state.json origin plus every recorded door the process table shows running as a front-door supervisor, and writes that list to headroom/run/allowlist.txt owner-only and atomically on any tick where the file differs, recording its hash in allowlistHash in the same tick.

Headroom (ExaDev/headroom#18, merged as ca7589c5 on 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's verifyAllowlistFile walks 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 a lastError naming 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), and headroom status (allowlistFileProblem: { message, repairable }) and doctor (a fail) report it whether or not a daemon is serving. So an AGENT_SHIM_HOME under /tmp cannot 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 ps read 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 with lastError, which quotes the redacted end of the daemon's log when it exits before it is ready.

ensureHeadroom keeps 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 (a headroom ... proxy on 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 status and doctor report 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's requiredUpstreams and the exported launchRequiredUpstreams, EnsureHeadroomPorts.daemonCommandLine, SupervisorPorts.spawnHeadroom taking the file path plus daemonCommandLine, frontDoorLiveness, daemonLogSize and readDaemonLogTail, socketTrust gaining readlink and its lstat reporting SocketPathStat's new "file" kind, fileProblem and allowlistFileProblem as { message, repairable }, the RunDoctorParams headroom and front-door shapes, and the headroom status --json fields), 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-headroom meanwhile, or stop the supervisor (agent-shim headroom status names its pid; kill <pid> stops it and its daemon).

Default source: HEADROOM_DEFAULT_SOURCE is pinned to headroom-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 with uv sync --frozen --extra proxy, with throwaway roots in the home directory and a door stand-in carrying the __frontdoor-supervisor argument. With headroom/ and run/ pre-created 0775, the supervisor logged the repair, narrowed both to 0700, wrote the file 0600 holding only the door's origin, and started headroom proxy --uds ... --allowed-base-urls-file .../run/allowlist.txt; the door's origin got 200 and a provider's base URL 400. A chmod 0640 of the file and a chmod 0770 of run/ 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 with lastError naming that directory, mode 0775 and chmod go-w; headroom status (allowlistFileProblem) and doctor (a fail) said the same with no daemon running. Headroom at ca7589c5, 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/joe was refused with chmod 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 /Users were accepted; a real root under /private/tmp was 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 real ensureHeadroom.

pnpm typecheck, pnpm lint, pnpm test, pnpm knip and pnpm schema pass locally, rebased on the current main.

@Mearman

Mearman commented Oct 11, 2026

Copy link
Copy Markdown
Member Author

Merging this produces a major release, because headroom status --json renames allowlistDrifted and allowlistDriftInputs to allowlistFile and allowlistDrift and the exported spawnHeadroom and EnsureHeadroomPorts signatures change. The alternative is to keep the old field names with the new meaning and drop the breaking marker. Undecided; settle it before this leaves draft. Also pending: the default headroom source pin moves only after ExaDev/headroom#18 merges.

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.
@Mearman
Mearman force-pushed the feat/headroom-allowlist-file branch from 499a63d to 6269969 Compare October 12, 2026 02:43
@Mearman
Mearman marked this pull request as ready for review October 12, 2026 02:52
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-10-12T02:57:55.163139Z 6269969 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit 1d4ad0c into main Oct 12, 2026
31 checks passed
@Mearman
Mearman deleted the feat/headroom-allowlist-file branch October 12, 2026 02:58
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.

1 participant