Young's Mind
03 Decisions

The uid-boundary fence — what is built, and the three root steps left

Min's record proved the hole: opencode authentication is per-uid, so any process running as lab could call any paid model with no gate ($22.38 lifetime spend through it). This record is the fix, built and committed.

The uid-boundary fence — what is built, and the three root steps left

Min's record proved the hole: opencode authentication is per-uid, so any process running as lab could call any paid model with no gate ($22.38 lifetime spend through it). This record is the fix, built and committed.

1. New measurements that changed the design (all proven, in the session)

  1. A config-file apiKey bypasses the db. Config-with-key + empty opencode.db → rc=0 on a live model call. So a runner config carrying a literal key would re-open the hole; the runner's config is keyless by construction and the bootstrap refuses to install one that leaks (_literal_key_paths). {env:...} references are kept — they resolve to nothing under the unit's UnsetEnvironment= and fail closed.
  2. The env var alone does NOT authenticate without the db: OPENCODE_GO_API_KEY set, no db → Error: Upstream request failed: Go requires a credential, rc=1. The db is the credential; the env var is not (this also re-proves E5: an env key cannot rescue a fenced process).
  3. The no-credential error is loud, not silent: empty db + paid model → Error: Model unavailable: <model>, rc=1. That is the criterion-5 shape.
  4. A systemd USER unit cannot switch uid: systemd-run --user -p User=root exits status=216/GROUP. The fence therefore needs a system unit (root) — a user-level fix was never available.
  5. The default opencode service port (49374) is global, not per-uid: a second uid's child collided (Managed service port 49374 ... already in use). The runner gets its own port (49375).

2. What is built (commit e1eb316 on origin/main)

FileRole
operation/runtime/ocrunner_bootstrap.pyroot bootstrap: creates ocrunner, builds /var/lib/ocrunner 0700, installs the keyless config, pipes the credential across uids (auth export → auth import, in-memory, secret never on disk or in output), sets the runner's port, shares operation/state/ with lab via setgid group. --check reports the fence state without root; --de-scope-lab is the deliberate final step.
etc/systemd/solos-v3-worker-fenced.servicethe system unit: User=ocrunner, SupplementaryGroups=lab, UMask=0002, E5's UnsetEnvironment= carried over, ProtectSystem=strict + explicit ReadWritePaths. systemd-analyze verify passes.
tests/live_proof_uid_fence.pyall five acceptance criteria as rows. Without root it reports NOT-RUN, never a substitute PASS; currently green: suite 457 passed, red check loud-fails as measured.

Design choices recorded in the code, not silently made: the equality-canary in the scrub was strengthened to a path-listed leak check; the cutover order is disable the lab worker BEFORE enabling the fenced unit (two workers on one store race for the next task); the proof sets umask 0002 because root creating files in a setgid dir umask-0022 would lock the runner out of its own db.

3. The three root steps that remain (Young, in this exact order)

# 1. stop the lab user-unit worker (as lab)
systemctl --user disable --now solos-v3-worker.service

# 2. bootstrap + install + enable
sudo python3 /home/lab/.solos/operation/runtime/ocrunner_bootstrap.py
sudo install -m 644 /home/lab/.solos/etc/systemd/solos-v3-worker-fenced.service \
    /etc/systemd/system/ && sudo systemctl daemon-reload && \
    sudo systemctl enable --now solos-v3-worker-fenced.service

# 3. prove all five criteria
sudo python3 /home/lab/.solos/tests/live_proof_uid_fence.py

# 4. only when the proof is green — close the lab side (deliberate):
sudo python3 /home/lab/.solos/operation/runtime/ocrunner_bootstrap.py --de-scope-lab

Why --de-scope-lab is not part of apply: it changes what a human shell can spend (lab re-auths with opencode auth login on demand; the key stays in /home/lab/.config/opencode/lab-go.env, mode 0600, for that). Making it an action keeps the fence from silently breaking a human workflow.

Rolled back = the steps in reverse; nothing else to undo (the fence adds a user, a unit and a config; it changes nothing existing until step 4).

4. Verification state, honestly

  • Offline suite: 457 passed, 2 skipped, 2 xfailed — under the interpreter whose site-packages carry the deps. (Measured on the way: the suite needs the A2A SDK on PYTHONPATH plus json-rpc; 7 "failures" in one earlier run were an environment gap — pydantic_core compiled for 3.12 vs 3.14 — not code. The proof discovers a working interpreter rather than assuming.)
  • Red check: PASS (loud Model unavailable, rc=1, no spend).
  • Criteria 1, 2-real, 3a, 4-runtime: NOT-RUN — they need the root steps. --check currently reports NOT APPLIED, which is the true state until sudo runs.
  • Not done and deliberate: no change to route()/invoke.py (the fence is a uid, not a code check — criterion 4); no second launcher; E5 intact.

5. For Mira, afterwards (the ledger ask stands)

Once criterion 2 holds, opencode stats --cost --json read under each uid becomes the truth about WHO spent: lab's total freezes (humans only by then), ocrunner's grows (the fleet). Difference = fleet spend, per day, without any attribution code. That is the cheapest ledger available, and it becomes possible only after the fence.

Copyright © 2026