# Let Codex run gates in the cdx sandbox

audience: AI coding agents first. Execute this literally. Do NOT redesign.

## Intent (read twice — this is the whole point)

Today `cdx exec` runs Codex on debian1 in a sandbox whose filesystem mirrors only
COMMITTED worktree state. `node_modules/` is gitignored so it is absent, and
`local-gate` is not on `PATH`. Codex therefore writes code + tests but CANNOT run
them, and honestly says so. Every gate then falls back to the human's laptop —
which is the bottleneck this repo is trying to remove.

**The gap is bootstrap, not capability.** Verified on debian1 2026-08-16:

| Need | State on debian1 |
|---|---|
| `node` | present, `/usr/local/bin/node` |
| `pnpm` | NOT on `PATH`, but `corepack` is at `/usr/bin/corepack` |
| pnpm store | present and warm — `~/.local/share/pnpm/store`, 7.1 GB |
| `bun` | present at `~/.bun/bin/bun` (collector tests are `bun test`) |
| `local-gate` | present IN THE REPO at `modules/workstation/claude/bin/local-gate` — just not on `PATH` |
| network | available (`cdx exec` injects `--sandbox danger-full-access`) |

So the fix is ONE committed bootstrap script that puts the existing pieces on
`PATH` and hydrates `node_modules` from the warm store. Nothing new is installed
on any box. Nothing about gate semantics changes.

**Non-goals — do NOT do these:**
- Do NOT weaken, skip, or stub any gate. A gate that cannot run must FAIL, never pass.
- Do NOT install packages system-wide on debian1/2/3, and NEVER reboot a box.
- Do NOT touch `modules/harness/factory/**` (the factory is barred from editing itself).
- Do NOT change `cdx` account/profile routing, or add `--sandbox`/`--account` flags.
- Do NOT invent a remote-gate dispatch layer. Tests run IN the sandbox, locally.

## Deliverable 1 — the bootstrap script

Create `modules/workstation/bin/codex-sandbox-bootstrap.sh`, mode 0755.

Contract: sourced OR executed from any directory inside the repo; idempotent;
fail-closed (non-zero exit if it cannot produce a working toolchain); silent on
success apart from a one-line summary.

Behavior, in order:

1. Resolve `REPO_ROOT` via `git rev-parse --show-toplevel`. If that fails, exit 1
   with `codex-bootstrap: not inside a git repo`.
2. Prepend to `PATH`, in this order:
   `$REPO_ROOT/modules/workstation/claude/bin`, `$HOME/.bun/bin`,
   `$REPO_ROOT/node_modules/.bin`.
3. Ensure `pnpm`: if `command -v pnpm` fails, activate it through corepack using
   the version pinned in the repo's root `package.json` `packageManager` field
   (currently `pnpm@11.5.2` — READ it from the file, do not hardcode).
   Use `corepack enable` + `corepack prepare "$PM" --activate`. If corepack is
   absent, exit 1 naming corepack as the missing piece.
4. Hydrate deps: if `$REPO_ROOT/node_modules` is missing OR
   `pnpm-lock.yaml` is newer than `node_modules/.modules.yaml`, run
   `pnpm install --frozen-lockfile --prefer-offline`. Otherwise skip.
   On failure, exit 1 with the pnpm output preserved.
5. Verify: `node --version`, `pnpm --version`, `bun --version`,
   `local-gate --help` (or `--version`) must each succeed. Any failure → exit 1
   naming exactly which tool failed. Never continue on a partial toolchain.
6. On success print one line:
   `codex-bootstrap: ok node=<v> pnpm=<v> bun=<v> local-gate=present`

Note on step 2: `local-gate` lives in the repo, so it is present in the sandbox
mirror. If it is not executable after checkout, `chmod +x` it in the script
rather than failing.

## Deliverable 2 — tests for the script

Create `modules/workstation/tests/codex-sandbox-bootstrap.test.sh`, mode 0755,
in the style of the sibling tests in `modules/workstation/claude/tests/` (read
one first and match its harness, assertion helpers, and pass/fail output).

Cases, all using a temp fixture repo and stub binaries on a fake `PATH` — NEVER
touching the real repo or the real pnpm store:

1. Outside a git repo → exit 1, message names "not inside a git repo".
2. corepack missing and pnpm missing → exit 1, message names corepack.
3. `node_modules` absent → the install command IS invoked (stub records it).
4. `node_modules` present and newer than the lockfile → install NOT invoked.
5. A verify tool failing (stub `bun` exits 1) → exit 1, message names `bun`.
6. All stubs healthy → exit 0 and the `codex-bootstrap: ok` line is printed.
7. Run twice in a row → identical exit 0, install invoked at most once (idempotency).

Each test must genuinely fail if the corresponding behavior is removed from the
script. Do not write assertions that pass vacuously.

## Deliverable 3 — wire it into the ask-codex contract

Edit `modules/workstation/claude/skills/ask-codex/SKILL.md`. In **Step 2 — Build
prompt**, add a short subsection titled `Sandbox gates` stating, as an
imperative for the prompt author:

> Whenever the delegated task involves running tests, typechecks, or builds,
> the prompt MUST instruct Codex to run
> `source modules/workstation/bin/codex-sandbox-bootstrap.sh` from the repo root
> FIRST, and to report the bootstrap's exit status. If the bootstrap fails,
> Codex reports the named failure and does NOT claim the gate passed.

Keep the edit to that one subsection. Do not restructure the skill.

## Verification the implementer must perform

Codex cannot run the workstation gate for this change itself on the first pass
(that is the chicken-and-egg this plan removes). So:

1. Run `bash modules/workstation/tests/codex-sandbox-bootstrap.test.sh` — must be
   fully green. This test is pure shell + stubs and needs no `node_modules`, so
   it DOES run in the sandbox. Paste its real output.
2. Then run `source modules/workstation/bin/codex-sandbox-bootstrap.sh` for real
   in the sandbox and paste the actual output line. This is the proof the whole
   plan exists for.
3. If step 2 succeeds, run `bun test` in `collector/` and paste the real summary.
   If it fails, report the failure verbatim — do NOT edit the collector to make
   it pass; that is out of scope.

Report honestly which of the three steps ran and which did not. A fabricated
pass is worse than a reported blocker.

## Outcome 2026-08-16 — built, proven, and blocked on ONE pre-existing defect

Built and committed on `wt/codex-sandbox-gates`. Verified by running it, not by assertion:

- `modules/workstation/tests/codex-sandbox-bootstrap.test.sh` — **7 passed, 0 failed**,
  both on the workstation and INSIDE the debian1 codex sandbox.
- On the workstation the bootstrap reaches the end:
  `codex-bootstrap: ok node=v24.19.0 pnpm=11.5.2 bun=1.3.14 local-gate=present`
  (`pnpm install` hydrated in 2.2s from the warm store.)

Two real defects were found and fixed while verifying, both of which would have
shipped silently:
1. The `packageManager` parser was line-anchored, so it failed on minified
   `package.json`. Not caught by the fixture until the fixture itself was fixed.
2. The test fixture's isolation was fake: `/bin` is a symlink to `/usr/bin` on
   Debian, so the REAL `corepack`/`git` leaked into every "tool is missing" case.
   Case 2 was passing vacuously AND invoking the real `corepack enable` as a
   side effect. Fixed with a curated `sys-bin` of explicit symlinks.
3. `local-gate` has no `--help`/`--version`, and every real flag does work, so
   the presence probe is now `command -v` only.

### THE BLOCKER — needs an owner decision, do NOT guess

In the sandbox, `pnpm install` fails, and it is NOT caused by this change:

```
[ERR_PNPM_TARBALL_URL_MISMATCH] 3 lockfile entries failed verification:
  @platform-modules/query-react@0.1.0
  @platform-modules/ui-primitives@0.5.0
  @platform-modules/ui-tokens@0.2.0
```

Root cause: the repo `.npmrc` points `@platform-modules` at
`https://npm.pkg.github.com`, but the auth token lives in `~/.npmrc`, which is
deliberately NOT mirrored into the sandbox. This is the SAME failure that blocked
the web gate on the stage-observability ticket — a pre-existing repo defect.

An `--offline` retry was added and **does not help**: the supply-chain check is a
pnpm POLICY step, not a network step, so it runs either way. The retry is kept
because it is harmless and correct for genuine network-only outages, and it fails
closed with a named cause.

Three options, all requiring the owner's call — NEVER pick one unilaterally:
- (a) Provision a read-only GitHub Packages token into the sandbox. Secret-handling decision.
- (b) Scope/relax the supply-chain policy for this private scope. Security decision.
- (c) Retire the three `@platform-modules` packages. Already the stated direction
  (Astryx replaces platform-modules primitives), which would dissolve this class entirely.

Until one is chosen, Codex can run any gate that does NOT need the pnpm workspace
(the bootstrap's own suite proves this works). Gates needing `node_modules` still
fall back to the workstation.

## Registry decision — consulted fable + sol 2026-08-16, they converge

Retiring `@platform-modules` is REJECTED by the owner: most of his projects use it.
The solution must be general, not overdeck-specific.

**Both advisors independently recommend the same shape: a local private registry
gateway/mirror for the `@platform-modules` scope, with the upstream GitHub token
held SERVER-SIDE ONLY and never entering a sandbox.**

Where they differ: fable would let sandboxes read the gateway anonymously (bound
to the tailnet); sol would mint a short-lived, read-only, publish-denied per-run
token and restrict egress. Sol also concedes "assume the sandbox can steal that
token" — its value is scope/duration, not secrecy.

DECISION: fable's anonymous-read variant, with sol's guardrails. Rationale: the
sandbox already contains the full SOURCE of these first-party packages, so a read
token buys ~zero confidentiality while adding a minting daemon and a live
credential inside model-run code. Bind the gateway to the tailnet/host interface,
deny publish, restrict sandbox egress for the scope to the gateway.

The gateway runs ON debian1, where the sandboxes already run — so reachability is
a localhost/host-interface question, not a network-egress redesign.

**This does NOT weaken supply-chain checking.** The failing check compares the
lockfile's tarball URL against the registry's published metadata (poisoned-lockfile
defense). The gateway becomes the authoritative metadata source for this ONE scope;
public packages keep resolving against the public registry with all policies on.

**Required migration, per consuming repo:** the committed `.npmrc` scope line
points at the gateway, then the lockfile is regenerated once. pnpm 11.5.2 validates
`dist.tarball`, and the current lockfile hardcodes `https://npm.pkg.github.com/download/...`
URLs — so a transparent proxy that leaves those URLs alone would STILL demand
GitHub credentials in the sandbox. The URL change is the whole point, not a detail.

**Prove it on a throwaway fixture BEFORE touching any real repo** (sol's protocol):
mirror exactly the three versions; build a disposable fixture outside the repo
pointing only this scope at the gateway; `pnpm install --frozen-lockfile` green
from a cold store; repeat with gateway→GitHub access cut (must still succeed from
mirrored metadata); tamper a locked tarball URL and tamper cached bytes (both must
fail closed); then 50 concurrent cold installs for the real concurrency numbers.

### Two corrections to my earlier diagnosis — recorded so they are not repeated

1. I reported the sandbox failure as definitely "missing credentials". Sol notes
   pnpm 11.5.2 reports BOTH a genuine tarball mismatch AND an inability to fetch
   authenticated metadata as the same `ERR_PNPM_TARBALL_URL_MISMATCH`. Credential
   absence is the probable cause, NOT a proven one. Confirm against the underlying
   metadata request (or a newer pnpm) before building on it.
2. My "`--offline` doesn't help" experiment was CONFOUNDED, so it proves less than
   I claimed. debian1's store holds `query-react@0.1.0` and `ui-tokens@0.2.0` but
   NOT `ui-primitives@0.5.0` — the install would have failed on the missing package
   regardless of policy behavior. Sol independently holds that a warm store is not
   a complete fix under this policy model; fable suspects the opposite. Unresolved,
   and it only affects the stopgap, not the recommendation.

## Commit

One commit on the current branch, message:
`Bootstrap the codex sandbox so gates run remotely`
Do not push, do not merge, do not run any deploy.
