fix(nix): let the registry build authenticate to npm.forgegraf.com #512

Merged
gmackie merged 1 commit from fix/nix-registry-npm-auth into main 2026-08-27 23:41:04 +00:00
Owner

npm-registry deploys fail with:

ERR_PNPM_FETCH_401  GET https://npm.forgegraf.com/@preflight/runreport/-/runreport-0.1.2.tgz: Unauthorized

The gap is pre-existing; #509 only exposed it. The registry's dependency fetch is a fixed-output derivation, and that sandbox cannot see /root/.npmrc — which does hold a valid token. It worked for as long as every dependency was public. Once apps/web took a private package, the first lockfile change that invalidated the cached FOD turned it into a hard failure. Nix'''s netrc-file does not help: pnpm reads .npmrc, not netrc, and that file has no npm.forgegraf.com entry anyway.

Worth noting the shape: agent/internal/registry/npmrc.go already solves exactly this for the CI path. The deploy path had no equivalent.

Fix. impureEnvVars is nix'''s supported way to pass a credential into a FOD. The fetch writes an .npmrc from FG_NPM_TOKEN when one is set. It goes under TMPDIR, never into $out, so the token cannot reach the store. With no token set, behaviour is byte-identical to before.

Verified on hetzner-fg before writing this, rather than reasoning about nix semantics:

  • patched the flake in a scratch clone of current main and ran a real nix build .#registry
  • the fetch that 401s unpatched pulled 5.7G of dependencies and realised the derivation
  • store path registered 19 seconds after the clone, so the output is unambiguously from that build
  • nix hash path --sri on it gives the hash recorded here

Requires wiring: FG_NPM_TOKEN must be in the agent'''s environment when it invokes nix build. I am adding it to /etc/forgegraph/agent.env on hetzner-fg (the existing EnvironmentFile the unit already reads). The durable version is to carry a registry token in the deployment payload the way CI jobs already do — PendingDeployment has no registry field today — which is a server+agent change worth doing separately rather than bundling here.

Note the hash also moved because the fetch now succeeds and produces a complete dependency set; the neighbouring comment about refreshing it on lockfile changes still applies.

🤖 Generated with Claude Code

npm-registry deploys fail with: ``` ERR_PNPM_FETCH_401 GET https://npm.forgegraf.com/@preflight/runreport/-/runreport-0.1.2.tgz: Unauthorized ``` **The gap is pre-existing; #509 only exposed it.** The registry's dependency fetch is a fixed-output derivation, and that sandbox cannot see `/root/.npmrc` — which does hold a valid token. It worked for as long as every dependency was public. Once apps/web took a private package, the first lockfile change that invalidated the cached FOD turned it into a hard failure. Nix'''s `netrc-file` does not help: pnpm reads `.npmrc`, not netrc, and that file has no `npm.forgegraf.com` entry anyway. Worth noting the shape: `agent/internal/registry/npmrc.go` already solves exactly this for the **CI** path. The **deploy** path had no equivalent. **Fix.** `impureEnvVars` is nix'''s supported way to pass a credential into a FOD. The fetch writes an `.npmrc` from `FG_NPM_TOKEN` when one is set. It goes under `TMPDIR`, **never into `$out`**, so the token cannot reach the store. With no token set, behaviour is byte-identical to before. **Verified on hetzner-fg before writing this**, rather than reasoning about nix semantics: - patched the flake in a scratch clone of current main and ran a real `nix build .#registry` - the fetch that 401s unpatched pulled **5.7G of dependencies** and realised the derivation - store path registered 19 seconds after the clone, so the output is unambiguously from that build - `nix hash path --sri` on it gives the hash recorded here **Requires wiring:** `FG_NPM_TOKEN` must be in the agent'''s environment when it invokes `nix build`. I am adding it to `/etc/forgegraph/agent.env` on hetzner-fg (the existing `EnvironmentFile` the unit already reads). The durable version is to carry a registry token in the deployment payload the way CI jobs already do — `PendingDeployment` has no registry field today — which is a server+agent change worth doing separately rather than bundling here. Note the hash also moved because the fetch now succeeds and produces a complete dependency set; the neighbouring comment about refreshing it on lockfile changes still applies. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(nix): let the registry build authenticate to npm.forgegraf.com
All checks were successful
CI / gitleaks (pull_request) Successful in 6s
CI / storybook (pull_request) Successful in 1m25s
forgegraph/ci CI passed
CI / ci (pull_request) Successful in 11m10s
f12f158f34
The registry's dependency fetch runs in a fixed-output derivation, and that
sandbox cannot see /root/.npmrc. It only ever worked because every dependency
was public; once apps/web took a private package the fetch started failing
with ERR_PNPM_FETCH_401 on the first lockfile change that invalidated the
cached output.

Nix's sanctioned way to get a credential into a fixed-output derivation is
impureEnvVars, so the fetch now writes an .npmrc from FG_NPM_TOKEN when one
is present. Written under TMPDIR, never into $out, so the token cannot reach
the store. Absent the variable the behaviour is exactly as before.

Proven on hetzner-fg before writing this: the patched fetch pulled 5.7G of
dependencies where the unpatched one 401s, producing the hash recorded here.
Author
Owner

Reviewed. The diagnosis is right — a FOD sandbox genuinely cannot see /root/.npmrc, and impureEnvVars is nix's supported escape hatch. Keeping the npmrc under $TMPDIR rather than $out is the correct instinct. Three things before this lands, one of them blocking.

1. Blocking: nothing sets FG_NPM_TOKEN, so this silently no-ops

FG_NPM_TOKEN appears only inside flake.nix — three references, all consuming it. Nothing exports it, and it is not a repo Actions secret:

FG_NPM_TOKEN present:      False
FG_REGISTRY_TOKEN present: True

Because the guard is if [ -n "${FG_NPM_TOKEN:-}" ], an unset token takes the silent branch and the fetch 401s exactly as before, with nothing in the log saying the credential was absent.

I did this to myself earlier today in #499: an auth block that looked right, guarded on the token being present rather than on the write landing, and the only symptom was the same downstream 401. Worth either reusing FG_REGISTRY_TOKEN (which exists) or adding the new secret, and making the empty case ::warning:: rather than silent.

2. impureEnvVars on a daemon build reads the DAEMON's environment

Every deploy path sources nix-daemon.sh (6 references in deploy.yml alone), so builds go through the daemon. For daemon builds the values of impureEnvVars are taken from the nix-daemon's environment, not from the shell that invokes nix build. Exporting it in the workflow step will not reach the FOD unless the daemon service itself carries it (systemctl edit nix-daemon / an Environment= drop-in). Worth confirming on the runner before relying on it.

3. It widens the token's blast radius, against this repo's own stated threat model

cloudflare_worker.go:665 documents the model explicitly:

argv is world-readable through /proc/<pid>/cmdline, and the fleet mounts /proc without hidepid while carrying non-root local users — including nixbld1..N, the accounts nix builds run as. A flake being built could otherwise read the token out of a concurrent clone's command line.

impureEnvVars is not scoped to this derivation. Any FOD built on that daemon can declare impureEnvVars = [ "FG_NPM_TOKEN" ] and receive the value — and ForgeGraph exists to build user-supplied flakes on those hosts. That is the same exfiltration path the git-clone code deliberately avoided, reintroduced through a different door.

Not necessarily disqualifying — the token is scoped to one private registry, and the alternative is a broken deploy — but it deserves to be a recorded decision rather than a side effect, and the comment should say so.

A narrower option

Pre-fetching the dependency store outside the FOD (where /root/.npmrc is visible) and passing it in, or vendoring the one private tarball, keeps the credential out of any sandbox. More moving parts; no shared-daemon exposure.

Also

The hash moves to sha256-BCdknkFh1h63QSsZJsOhdeluh8WrDUVWw0C1oRdMOQw= because the lockfile now takes @preflight/runreport@0.1.2. Worth confirming it was computed against this exact tree rather than an earlier one — the value in #491's description differed from the computed hash by a single character (…KPYVfGFW… vs …KPYVfGFQ…), which would have reproduced the identical failure while looking correct.

Reviewed. The diagnosis is right — a FOD sandbox genuinely cannot see `/root/.npmrc`, and `impureEnvVars` is nix's supported escape hatch. Keeping the npmrc under `$TMPDIR` rather than `$out` is the correct instinct. Three things before this lands, one of them blocking. ## 1. Blocking: nothing sets `FG_NPM_TOKEN`, so this silently no-ops `FG_NPM_TOKEN` appears only inside `flake.nix` — three references, all consuming it. Nothing exports it, and it is not a repo Actions secret: ``` FG_NPM_TOKEN present: False FG_REGISTRY_TOKEN present: True ``` Because the guard is `if [ -n "${FG_NPM_TOKEN:-}" ]`, an unset token takes the silent branch and the fetch 401s exactly as before, with nothing in the log saying the credential was absent. I did this to myself earlier today in #499: an auth block that looked right, guarded on the token being *present* rather than on the write *landing*, and the only symptom was the same downstream 401. Worth either reusing `FG_REGISTRY_TOKEN` (which exists) or adding the new secret, and making the empty case `::warning::` rather than silent. ## 2. `impureEnvVars` on a daemon build reads the DAEMON's environment Every deploy path sources `nix-daemon.sh` (6 references in `deploy.yml` alone), so builds go through the daemon. For daemon builds the values of `impureEnvVars` are taken from the **nix-daemon's** environment, not from the shell that invokes `nix build`. Exporting it in the workflow step will not reach the FOD unless the daemon service itself carries it (`systemctl edit nix-daemon` / an `Environment=` drop-in). Worth confirming on the runner before relying on it. ## 3. It widens the token's blast radius, against this repo's own stated threat model `cloudflare_worker.go:665` documents the model explicitly: > argv is world-readable through `/proc/<pid>/cmdline`, and the fleet mounts `/proc` without hidepid while carrying non-root local users — including nixbld1..N, the accounts nix builds run as. A flake being built could otherwise read the token out of a concurrent clone's command line. `impureEnvVars` is not scoped to this derivation. **Any** FOD built on that daemon can declare `impureEnvVars = [ "FG_NPM_TOKEN" ]` and receive the value — and ForgeGraph exists to build user-supplied flakes on those hosts. That is the same exfiltration path the git-clone code deliberately avoided, reintroduced through a different door. Not necessarily disqualifying — the token is scoped to one private registry, and the alternative is a broken deploy — but it deserves to be a recorded decision rather than a side effect, and the comment should say so. ### A narrower option Pre-fetching the dependency store outside the FOD (where `/root/.npmrc` is visible) and passing it in, or vendoring the one private tarball, keeps the credential out of any sandbox. More moving parts; no shared-daemon exposure. ## Also The hash moves to `sha256-BCdknkFh1h63QSsZJsOhdeluh8WrDUVWw0C1oRdMOQw=` because the lockfile now takes `@preflight/runreport@0.1.2`. Worth confirming it was computed against this exact tree rather than an earlier one — the value in #491's description differed from the computed hash by a single character (`…KPYVfGFW…` vs `…KPYVfGFQ…`), which would have reproduced the identical failure while looking correct.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gmackie/ForgeGraph!512
No description provided.