Skip to content

feat(cli): support T3 Code mobile access over Tailscale - #897

Merged
ryaneggz merged 8 commits into
developmentfrom
feat/858-tailscale-t3-mobile
Aug 31, 2026
Merged

feat(cli): support T3 Code mobile access over Tailscale#897
ryaneggz merged 8 commits into
developmentfrom
feat/858-tailscale-t3-mobile

Conversation

@ryaneggz

@ryaneggz ryaneggz commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #858.

Architecture decision

tailscaled runs inside the sandbox in userspace-networking mode as the unprivileged sandbox user (--tun=userspace-networking, socket and state under $HOME/.tailscale). T3 Code stays bound to container loopback; t3 serve --tailscale-serve advertises it over tailnet HTTPS.

This grants zero new privileges: no NET_ADMIN, no /dev/net/tun, no privileged, no published port, no overlay file. The compose surface gains exactly one env passthrough (INSTALL_TAILSCALE) and one named state volume.

Rejected alternative: tailscaled on the remote host with the sandbox port routed through the host tailnet. It forces publishing 3773, breaks oh tool's in-sandbox contract, and splits the network boundary across two machines.

What changed

Optional tool. install.tailscale in oh.json maps to INSTALL_TAILSCALE, wired through oh-config, env-file, config-render, the oh init wizard, and oh tool install/list/status. The catalog entry mirrors agent-browser's persist-first shape; commands/tool.ts needed no change. The entrypoint installs a pinned Tailscale 1.102.3 tarball with per-arch sha256 verification, is idempotent, and never starts the daemon or reads an auth key.

T3 skill. /t3 [start|status|url|pair|logs|stop|attach|doctor|help] with --tailscale, --tailscale-port, --session, --port, --log.

  • start -> npx --yes t3 serve; start --tailscale -> npx --yes t3 serve --tailscale-serve
  • pair / pair --tailscale adds a device against a running server, no restart
  • url prints the current reachable pairing URL, .ts.net included
  • doctor runs first on every start and fails with actionable guidance on: Node outside ^22.16 || ^23.11 || >=24.10, missing tailscale binary, tailscaled unreachable, NeedsLogin, and an unreachable T3 port
  • The server stays under the existing tmux convention, so it survives disconnect

Bare npx t3 is documented as the local desktop launcher and is no longer used for headless launches.

Docs. docs/harnesses/t3code.md rewritten; docs/connecting.md gains an end-to-end mobile recipe (prerequisites, tailscale up, phone join, QR pairing, reconnect, lifecycle, troubleshooting, revocation, and where Tailscale runs and why); plus rows in docs/installation.md, docs/configuration.md, and a Caveat 4 in docs/security-considerations.md. Two stale claims corrected: T3 does not bind 0.0.0.0:3773, and minting a second token no longer requires a restart. Cloudflared stays documented as the public preview tool, not the mobile path.

Security

Funnel is never enabled and no funnel token exists under .oh/skills/t3/ or .devcontainer/. Nothing binds 0.0.0.0. Interactive tailscale up is the documented setup; no reusable auth key is committed or logged. Both revocation paths — T3 pairing credentials and Tailscale device access — are documented.

Verification

  • pnpm run typecheck clean; pnpm run build:harness clean
  • npx vitest run — 947 passed. 8 failures in .oh/scripts/__tests__/compose-args.test.ts are pre-existing and environment-dependent (an ambient docker-compose.ssh.yml overlay); re-proved byte-identical against a clean git archive HEAD tree, both in failure set and failure mode, before and after the docker-compose.yml edit
  • bash .oh/skills/eval/run.sh — 103 probes, 99 PASS / 4 SKIPPED / 0 REGRESSION. The 4 skips are pre-existing and environmental
  • shellcheck -S warning clean on every touched shell file

Two new probes, each break-tested (property broken -> REGRESSION observed -> restored):

  • tailscale-tool-boundary.sh — 12 breaks caught: published 3773, cap_add, devices, a daemon start, a tailscale up, a per-arch pin disagreement, a catalog/entrypoint sha mismatch, a removed sha256sum -c, a Dockerfile build arg, a flipped catalog kind, a Funnel invocation, a committed tskey-auth- literal
  • t3-headless-launch.sh — 7 breaks caught, including bare t3, a broken --tailscale-serve mapping, a dropped tmux -d, and a --host 0.0.0.0

Not verified here — manual acceptance still required

This environment has no tailnet, no auth key, and no phone. The following acceptance criteria rest on the upstream T3 docs and are documented-only:

  • a real tailnet join, and that t3 serve --tailscale-serve actually publishes on 443 and prints a .ts.net URL
  • that t3 pair mints a token against a live server, and that the url regex matches T3's real output
  • phone pairing and reconnect in the T3 Code mobile app
  • that BackendState parsing matches real tailscale status --json
  • the arm64 sha256 came from upstream's published .sha256, not a byte-for-byte local download (amd64 was downloaded and verified)
  • no tailscale binary was ever executed in this environment. Every tailscale / tailscaled invocation in the docs, the skill, and the probes is stubbed or read, never run. CLI-to-daemon reachability of the documented invocation is therefore unexercised — that is exactly where the original --socket blocker hid, and it is closed by inspection of upstream's default socket path, not by a live round trip.

"A phone outside the tailnet cannot reach the backend" is discharged structurally by tailscale-tool-boundary.sh — no published port, no capabilities — rather than by a live negative test.

Follow-up

Scope 5 of the issue (matching guidance in mifunedev/openharness-web) is not in this PR. That repo is a separate checkout on its own branch and needs its own PR linking #858.

Add Tailscale as an opt-in `oh tool` and give T3 Code a headless,
tailnet-private mobile access path.

Tailscale runs inside the sandbox in userspace-networking mode as the
unprivileged sandbox user. T3 Code stays on container loopback and
`t3 serve --tailscale-serve` advertises it over tailnet HTTPS. This adds
no capabilities, no /dev/net/tun, and no published port; the compose
surface gains only an INSTALL_TAILSCALE passthrough and a named state
volume. The entrypoint installs a pinned, sha256-verified tarball and
never starts the daemon or handles an auth key.

The /t3 skill gains serve, pair, url, and doctor. Headless launches use
`t3 serve` under the existing tmux convention, `t3 pair` adds a device
without restarting the server, and doctor fails with actionable guidance
on Node range, tailscaled state, tailnet login, and port reachability.

Two probes guard the result: tailscale-tool-boundary asserts the
no-capability, no-port, no-Funnel, pinned-and-hash-matched boundary, and
t3-headless-launch asserts the serve-only launch surface.

Closes #858
@ryaneggz

Copy link
Copy Markdown
Collaborator Author

Audit — issue #858 / PR #897

Adversarial audit against the task PLAN and the issue's acceptance criteria. Every claim below was read out of the files, not taken from the executor reports or this PR body. Probes were mutation-tested, and both Tailscale sha256 pins were checked against upstream.

Verdict: PASS-WITH-FOLLOWUPS — one BLOCKER must land before merge. The plumbing, tests, docs, and probes are genuinely good work. One cross-workstream seam breaks the primary end-to-end flow.

What verified clean

  • Pins are real. curl https://pkgs.tailscale.com/stable/tailscale_1.102.3_{amd64,arm64}.tgz.sha256 returns exactly the two literals in .devcontainer/entrypoint.sh and .oh/cli/src/lib/tools/catalog.ts. Version and both shas agree across entrypoint / catalog / test / probe. No drift.
  • install.tailscaleINSTALL_TAILSCALE is consistent across oh-config.ts, env-file.ts, config-render.ts, init.ts, oh.json, install.sh, migrate-harness-yaml.sh, docker-compose.yml, and docs/configuration.md. No key-name drift.
  • Boundary held. Compose gains exactly one env passthrough and one named volume. No cap_add, devices:, privileged:, no ports: for 3773, no devcontainer.json change, no overlay. The entrypoint never runs tailscaled and never reads an auth key.
  • Probes are load-bearing, not theatre. Mutation-tested in a scratch tree: publishing 3773 → REGRESSION; one catalog sha flipped by a character → REGRESSION naming both sides; neutering the doctor's tailscale check → REGRESSION. t3-headless-launch.sh actually executes the script under stubbed tmux/node/npx/tailscale and asserts the captured launch string — that is a real oracle. The tskey-auth- scan is written so the probe's own regex does not self-match.
  • AGENTS.md: no explanatory comments added to tracked code; canonical .oh/ sources edited (the .claude/skills/t3 mirror resolves to them); both long-running processes are tmux-detached.
  • WS4's judgment call is right. Dropping bare npx t3 from the script loses nothing: t3 serve is the same server, print_summary still advertises http://localhost:3773, and the container has no browser for the GUI launcher to open. Keeping bare t3 as documented desktop guidance is the correct split.

BLOCKER 1 — the custom tailscaled socket makes the documented flow non-functional

.oh/skills/t3/scripts/t3-code.sh:108,137,143 · docs/connecting.md:165,172,180,236,237,247-253 · .oh/skills/t3/references/tailscale-mobile.md:32

The daemon is documented and hinted as tailscaled --socket=$HOME/.tailscale/tailscaled.sock ..., but every consumer then calls the CLI bare.

Upstream ground truth: paths.DefaultTailscaledSocket() returns /var/run/tailscale/tailscaled.sock on Linux, and cmd/tailscale/cli/cli.go takes that default with only a --socket flag to override it — there is no TS_SOCKET environment variable. So a daemon on a custom socket path is invisible to:

  1. t3-code.sh:137 tailscale status --json → the preflight always reports "tailscaled is not running or its socket is unreachable", so /t3 start --tailscale can never pass doctor even on a correctly configured node;
  2. every bare tailscale status / tailscale serve --https=443 off / tailscale logout / tailscale serve status in the docs and revocation section;
  3. T3 Code's own --tailscale-serve implementation, which drives Tailscale through the same default socket.

The files also disagree with each other: docs/connecting.md:172 says tailscale --socket=... up while references/tailscale-mobile.md:35 says bare tailscale up. Both cannot be right.

Minimal fix — stop using a custom socket:

  • .devcontainer/entrypoint.sh:552, next to the existing install -d: add install -d -o sandbox -g sandbox -m 0755 /var/run/tailscale 2>/dev/null || true (recreated on every boot, so the tmpfs lifetime is fine).
  • Mirror the same line into installArgv in .oh/cli/src/lib/tools/catalog.tstool-catalog.test.ts asserts entrypoint/catalog parity, so the two must move together.
  • Document the daemon as tailscaled --tun=userspace-networking --statedir=$HOME/.tailscale and drop --socket from docs/connecting.md:165,172, references/tailscale-mobile.md:32, and the tailscaled_hint() at t3-code.sh:108. Every bare tailscale ... call then works, and so does T3's own Serve call.

Bolting --socket onto the doctor instead would fix (1) and (2) but not (3), so it is not a real fix.

SHOULD-FIX 1 — the Node-floor assertion is Goodhartable

.oh/evals/probes/t3-headless-launch.sh:44 proves the floor with grep -qF '22.16', which the help text alone satisfies. Proof: replacing elif ! node_version_ok; then with elif false; then in t3-code.sh:130 leaves the probe PASSing. Every other assertion in this probe is behavioral; this one is not.

Fix: add a third stub dir whose node prints v22.15.0, run doctor, and assert a non-zero exit whose output names the range.

SHOULD-FIX 2 — the pairing token lands in a world-readable file

.oh/skills/t3/scripts/t3-code.sh:329: > "$LOG" creates /tmp/agent-t3code.log at the default umask (0644) and then tees T3's stdout into it, which is exactly where the one-time pairing URL appears. The PR correctly says "treat pairing URLs as secrets"; the log does not.

Fix: (umask 077; : > "$LOG").

NITs

  1. .oh/evals/probes/tailscale-tool-boundary.sh guards cap_add / devices: / privileged: / ports: but not network_mode: host, sysctls:, or devcontainer.json's appPort / forwardPorts / runArgs — alternate ways to reintroduce exactly the exposure this probe exists to prevent.
  2. t3-code.sh:107 tailscaled_hint() uses cat <<HINT; under a minimal PATH it dies with cat: command not found (observed live while mutation-testing). Use printf.
  3. docs/connecting.md:163 starts the daemon without teeing a log, while references/tailscale-mobile.md:31 tees to /tmp/agent-tailscaled.log and the session table names that file. Align them.
  4. .oh/scripts/install.sh:444 — column alignment of the new _opt_install TAILSCALE line does not match its neighbours.

On the PR body's "Not verified here"

Honest and unusually thorough — but incomplete in the one place that mattered. It does not disclose that no tailscale binary was ever executed in this environment, so the reachability of the documented daemon invocation from the CLI was never exercised. That is the exact gap BLOCKER 1 sits in. Suggest adding a line to that effect alongside the fix.

Acceptance criteria

# Criterion Status
1 oh tool list / status tailscale report enabled, installed, version MET — catalog entry + tool.test.ts JSON status case; commands/tool.ts needed no change
2 install persists config and installs the runtime path MET--persist-only and live-install cases; pinned, checksummed, idempotent, root-installed
3 Clean environment joins a tailnet and exposes T3 privately NOT MET as documented — BLOCKER 1; the recipe cannot complete as written
4 /t3 start --tailscale produces a usable pairing URL NOT MET as shipped — the preflight blocks it (BLOCKER 1); the launch string itself is proven correct by probe
5 t3 pair --tailscale adds a device without restart MET-BY-DOCS-ONLYpair action implemented and probe-asserted; upstream behavior unexecuted
6 Phone pairs and reconnects MET-BY-DOCS-ONLY — no phone here; disclosed
7 Phone outside the tailnet cannot reach the backend MET structurally — no published port, no capability, loopback-only; mutation-proven probe
8 Survives shell/SSH disconnect METtmux new-session -d asserted from a captured launch string, not a grep
9 Missing/unhealthy Tailscale and bad Node fail with actionable guidance MET for the missing-binary and unreachable-daemon paths (executed by probe); Node path implemented but only string-asserted (SHOULD-FIX 1) — and the unreachable-daemon error currently fires as a false positive (BLOCKER 1)
10 Unit/catalog/config drift tests cover the tool and launch behavior MET — 6 test files + 2 probes; catalog/entrypoint parity enforced on version and both shas
11 Docs corrected with an end-to-end mobile recipe MET apart from the socket lines in BLOCKER 1; the two stale claims (0.0.0.0:3773, restart-to-mint) are genuinely corrected
12 Linked openharness-web PR NOT MET, correctly deferred — separate repo, disclosed as follow-up

Fix BLOCKER 1 and criteria 3, 4, and 9 close.

The documented daemon ran on $HOME/.tailscale/tailscaled.sock while every
consumer — /t3 doctor, the doc'd tailscale status/serve/up/logout, and T3
Code's own --tailscale-serve — called the CLI bare, which only ever reaches
/var/run/tailscale/tailscaled.sock. There is no TS_SOCKET override, so
`/t3 start --tailscale` could never pass preflight.

The entrypoint and the tool catalog now create /var/run/tailscale owned by
sandbox, and every documented daemon and CLI invocation drops --socket.
--statedir and the tailscale-state volume are unchanged.

The Node-floor assertion in t3-headless-launch.sh becomes behavioral: a
stub node printing v22.15.0 must make doctor fail and name the range.

/t3 start creates its tee log under umask 077 so the one-time pairing URL
is not world-readable.
@ryaneggz

Copy link
Copy Markdown
Collaborator Author

Re-audit of 5a3be14b — all three findings closed

Delta-only audit. Every claim re-verified against the files and against upstream Tailscale source; probes re-run and re-broken.

Verdict: PASS. Merge-ready.

BLOCKER 1 (socket) — FIXED, and the mechanism checks out

  • .devcontainer/entrypoint.sh:553 and the installArgv tail in .oh/cli/src/lib/tools/catalog.ts both pre-create /var/run/tailscale as sandbox:sandbox 0755. grep -rn 'socket=' docs/ .oh/skills/t3/ → empty; TS_SOCKET appears nowhere. All three daemon commands and tailscale up are now bare.
  • The pre-creation is genuinely required, not belt-and-braces. cmd/tailscaled/tailscaled.go MkdirAlls only the state dir; safesocket's Linux listen() does os.Remove(path) and binds, and never creates the socket's parent. A non-root daemon cannot create /var/run/tailscale itself, so without this line the fix would not work.
  • The default path matches. paths.DefaultTailscaledSocket() returns /var/run/tailscale/tailscaled.sock whenever /var/run stats as a directory. Confirmed in the image: /var/run -> /run, which is a real directory.
  • Permissions are sufficient. 0755 owned by sandbox gives the daemon the directory write bit it needs to unlink and bind, and gives the same-user CLI traverse plus socket access. Daemon and CLI are both sandbox, so socket ownership is never the constraint.
  • No wipe window. /run is overlayfs here, not tmpfs, and the block runs on every container start under the same INSTALL_TAILSCALE gate — so it is recreated on oh restart / recreate regardless. The daemon is only ever started by hand afterwards, so nothing races it.
  • The 2>/dev/null || true asymmetry is safe. The entrypoint suppresses so a failure cannot break boot; the catalog runs under set -e with this as the final command, as root, on a path that exists — practically unfailable, and a failure would be a loud non-zero from oh tool install after the binaries are already in place, which an idempotent re-run clears. Parity test re-run: tool-catalog.test.ts 30/30 green.

SHOULD-FIX 1 (Node floor) — FIXED, and it is now a real oracle

The grep '22.16' is gone, replaced by a stubbed node v22.15.0 run of doctor. I re-broke it two ways:

  • elif ! node_version_okelif false: REGRESSION ("accepted Node v22.15.0 — the Node floor is not enforced at runtime")
  • keep the check but drop ${NODE_RANGE} from the error string: REGRESSION ("does not name the supported range")

Restore → PASS. Both the offending version and the range are independently asserted; neither can be satisfied by text sitting in the help block.

SHOULD-FIX 2 (log mode) — FIXED, and the unrequested chmod is the load-bearing half

Verified empirically: on a pre-existing 0644 log, (umask 077; : > "$LOG") alone leaves it 0644 — truncation does not change mode — and the chmod 600 takes it to 0600. A subsequent tee preserves 0600. So the extra line the executor added unprompted is the part that actually closes the case it was aimed at. No other writer touches the file.

Delta hygiene

No comments added to tracked code. No compose, devcontainer, capability, device, privileged flag or published port change — tailscale-tool-boundary.sh still PASSes. The PR body's "Not verified here" now names the exact gap the blocker hid in, including that the fix rests on inspection of upstream's default socket path rather than a live round trip. That is accurate and complete.

One new NIT

/var/run/tailscale is the only step of the install recipe duplicated across entrypoint.sh and catalog.ts with no drift guardgrep -rn 'var/run/tailscale' .oh/cli/src/__tests__/ .oh/evals/probes/ is empty, while version, both shas, the download base and the /usr/local/bin paths are all parity-asserted. The one line that fixes the blocker is the one line that can silently drift.

Minimal fix, in the tailscale matches the entrypoint block of tool-catalog.test.ts:

it("pre-creates the default tailscaled socket directory in both places", () => {
  expect(ts.installArgv!.join(" ")).toContain("/var/run/tailscale");
  expect(ENTRYPOINT).toContain("/var/run/tailscale");
});

Acceptance criteria movement

# Before Now
3 NOT MET MET-BY-DOCS-ONLY — the recipe is internally consistent and matches upstream's default socket; a live tailnet join remains unexecutable here and is disclosed
4 NOT MET MET — the preflight no longer false-positives, and the launch string npx --yes t3 serve --tailscale-serve under detached tmux is proven by an executing probe. T3's own emission of the .ts.net pairing URL stays upstream-dependent and disclosed
9 partial MET — missing binary, unreachable daemon, and out-of-range Node are all exercised by stubbed runs asserting both exit code and message content

Carried forward as optional, none blocking: the boundary probe still does not guard network_mode: host / sysctls: / devcontainer.json appPort; tailscaled_hint() still shells out to cat; docs/connecting.md step 2 still does not tee /tmp/agent-tailscaled.log while the reference and its session table do.

)

The /var/run/tailscale pre-creation is duplicated between the entrypoint
and the catalog installArgv but was the only install step with no parity
assertion. Version, both sha256 literals, the download base, and the
/usr/local/bin paths were already guarded.
@ryaneggz

Copy link
Copy Markdown
Collaborator Author

Audit complete — sign-off

0c3b2b58 verified. git diff 5a3be14b..0c3b2b58 is exactly one file, +5 −0: the parity guard in the tailscale matches the entrypoint block. Nothing else in the tree moved.

The guard is load-bearing, not tautological. Break-tested in both directions:

  • catalog path drifted to /var/run/tsX1 failed | 30 passed, and the one failure is pre-creates the socket directory the entrypoint also pre-creates
  • the install -d … /var/run/tailscale line deleted from entrypoint.sh → same single failure

Restored → 31/31 pass, no collateral either way. Both new probes still PASS, and the working tree matches origin exactly.

Findings ledger — all closed

Finding Status
BLOCKER 1 — custom tailscaled socket made the documented flow non-functional Closed in 5a3be14b; mechanism verified against upstream paths/safesocket/tailscaled source, not just against the diff
SHOULD-FIX 1 — Node-floor assertion was Goodhartable Closed in 5a3be14b; re-broken two independent ways, both REGRESS
SHOULD-FIX 2 — pairing token in a 0644 log Closed in 5a3be14b; mode transition verified empirically, tee preserves 0600
NIT — the one install step with no drift guard Closed in 0c3b2b58; break-tested above

CI green 4/4. Merge-ready.

Standing caveat — manual acceptance still required

This is unchanged and correctly disclosed in the PR body. No tailscale binary was ever executed in this environment, and there is no tailnet and no phone here. These remain documented-only and want a human pass on a real tailnet before the feature is called proven end to end:

  • a real tailnet join, and t3 serve --tailscale-serve actually publishing on 443 and printing a .ts.net URL
  • t3 pair --tailscale minting a token against a live server, and the url regex matching T3's real output
  • phone pairing and reconnect in the T3 Code mobile app
  • BackendState parsing against real tailscale status --json

"A phone outside the tailnet cannot reach the backend" stays discharged structurally — no published port, no capability, loopback-only — by a probe I mutation-tested rather than by a live negative test.

Three optional NITs remain open and none blocks merge: the boundary probe does not guard network_mode: host / sysctls: / devcontainer.json appPort; tailscaled_hint() shells out to cat; docs/connecting.md step 2 does not tee /tmp/agent-tailscaled.log while the reference and its session table do.

The tailscale tool entry was installUser:"root", writing both binaries into
/usr/local/bin. #908 proved that shape is unusable from inside the sandbox:
commands/tool.ts execs with stdio:"inherit", so local-target.ts selects the
INTERACTIVE branch — plain `sudo --`, no -n — and /etc/sudoers.d/sandbox grants
`sandbox ALL=(ALL) ALL` with no NOPASSWD. `oh tool install tailscale` therefore
hung on a password prompt no agent can answer. t3-code.sh's doctor already
printed that exact command as its remedy.

Nothing here needed root. Verified by running it: with
--tun=userspace-networking, tailscaled starts as the sandbox user, creates its
socket, and answers `tailscale status`. The only "operation not permitted" lines
are UDP buffer sizing, which Tailscale itself labels throughput-only warnings.

Both install paths — the catalog entry and the entrypoint's boot-time install —
now install into $NPM_USER_PREFIX/bin as the sandbox user. That also fixes a
second defect: /usr/local/bin is an image-layer path, so the old location was
discarded on every container recreate and re-downloaded 33 MB + 42 MB each time,
while leaving a root-owned binary no running sandbox could upgrade in place.

/var/run/tailscale still needs root to create, since tailscaled defaults its
control socket there and t3-code.sh calls a bare `tailscale status`. The
entrypoint keeps creating it, but no longer behind the INSTALL_TAILSCALE guard:
`oh tool install tailscale` promises the tool is usable in the already-running
container, and gating the socket directory would make that wait for a reboot.

The probe now holds all of it: no root install, no /usr/local/bin in either
path, and the socket directory created outside the guard.
The PR was DIRTY and could not merge, so the Tailscale fix could not land.
Three conflicts, all from #898/#899 collapsing the eleven per-tool volumes into
one /home/sandbox mount:

- docker-compose.yml: took development's single-mount form and dropped the
  `tailscale-state` volume this branch added. Its purpose — "the node does not
  re-authenticate on every container recreate" — is preserved for free, because
  ~/.tailscale now sits inside the one home mount. Re-added the
  INSTALL_TAILSCALE environment entry, which taking development's side had
  silently dropped; a catalog test caught that.
- docs/installation.md: took development's rewritten storage section and
  re-landed this branch's Tailscale prose against it, minus the per-tool volume
  bullet that no longer describes anything.
- .oh/evals/RESULTS.md: regenerated by running the suite.

Also retired the `tailscale-state` references left in docs/connecting.md and
docs/security-considerations.md, which described a volume that no longer exists.

Two test updates the fix required:
- tool.test.ts asserted `-u root` for the tailscale install.
- tool-catalog.test.ts asserted the binaries land in /usr/local/bin and that the
  install pre-creates /var/run/tailscale. The socket directory is root-owned and
  now belongs to the entrypoint alone.

Removed a source-level scan for interpolating template literals that I had
added: it cannot distinguish a JS backtick from a backtick inside prose, so it
passed or failed on catalog ORDER rather than on the hazard. The per-token
`${` ban, with the `bash -lc` body exempted, is the honest version.
ryaneggz added a commit that referenced this pull request Aug 31, 2026
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.

* task: stop baking the default harnesses into the sandbox image

PR #903 wired provision-harnesses.sh into the boot path but shipped it behind
ARG BAKE_HARNESSES=true, so every default harness was already present when the
provisioner ran and the install path never executed. All four defects that PR's
audit found lived in code a green CI run and a normal boot both skip.

Delete the bake rather than flip its default: remove ARG BAKE_HARNESSES, ARG
AGENTS, the PKG map and the $AGENTS loop in `base`, and the gated pi install in
`home`. A build arg that can re-bake is a dormant path that would restore both
the shadowed /usr/lib/node_modules copy and the untested boot install.

Make the install path CI-visible, since it is now load-bearing on every boot:

- The boot smoke asserts the outcome — each default harness resolves under
  NPM_USER_PREFIX via `type -P`, is owned by the reconciled sandbox uid, and
  prints its own version — and refuses to pass when the catalog reports no
  defaults. It boots on a fresh home volume, so this runs real npm work.
- verify-sandbox-image.sh gains the negative: reading the catalog out of the
  image itself, no kind:"default" harness may be installed.
- start_period goes 300s -> 600s in both compose files to cover the install,
  and the boot-guard probe now derives the smoke deadline from the healthcheck
  window instead of pinning a literal that a start_period bump could invert.
- The probe and unit assertions invert from "the bake is gated" to "no default
  harness package appears in the Dockerfile", reading the package names out of
  installArgv so they cannot drift from the catalog.

cc-safety-net stays baked. Opt-in INSTALL_* harnesses are untouched.

Costs this accepts, documented in installation.md: a first boot on a fresh home
mount needs network and runs 60-180s longer; an offline first boot yields a
usable shell with no agent CLIs; ~/.npm now lives in the home mount.

Closes #904

* task: cite the measured first-boot provisioning time

* task: move herdr and cloudflared out of the image into the tool catalog

Per the ownership boundary — the in-sandbox CLI provisions harnesses and tools —
herdr and cloudflared are tools, so the image should not carry them. #905 did
this for the default harnesses; this does it for the default tools.

The obvious template does not work. #897's tailscale entry root-installs to
/usr/local/bin, and commands/tool.ts:309 passes stdio:"inherit", so
local-target.ts:113-116 selects the INTERACTIVE branch — plain `sudo --`, no
-n. /etc/sudoers.d/sandbox grants `sandbox ALL=(ALL) ALL` with no NOPASSWD, so
`oh tool install <root tool>` hangs on a password prompt no agent can answer.
Verified in a running sandbox: `sudo -n -- true` → "a password is required".
(#897's tailscale has the same defect; flagged there, not fixed here.)

So install to ~/.local/bin as the sandbox user instead, the same correction
#900 made for the harnesses. No sudo, survives container recreation in the home
mount, and upgradeable in place by a running sandbox.

- ToolKind gains "default". herdr 0.7.4 and cloudflared 2026.8.2 become
  kind:"default", installUser:"sandbox", with per-arch pinned URLs and
  sha256 verification into $NPM_USER_PREFIX/bin. Checksums measured by
  downloading both arches, not copied from anywhere.
- provision-harnesses.sh generalizes over both catalogs and becomes
  provision-defaults.sh (OH_PROVISION_DEFAULTS, timeout 180s → 240s). It
  dies rather than reporting success when neither catalog yields a default.
- The Dockerfile loses the herdr RUN, ARG HERDR_VERSION, and the whole
  cloudflared apt block — with it the bookworm-suite workaround that existed
  only because Cloudflare publishes no trixie suite. Docker's is now the only
  third-party apt source.
- Both oracles generalize: verify-sandbox-image.sh rejects a baked default
  harness OR tool, reading each catalog out of the image; the boot smoke
  asserts every default in both catalogs resolves under NPM_USER_PREFIX,
  is owned by the sandbox uid, and prints a version.
- The herdr version+checksum pin moves from the Dockerfile to the catalog,
  and herdr-default.test.ts follows it.

Costs, documented in installation.md: an offline first boot on a fresh home
mount now has no herdr, so `oh shell` lands in a plain shell with tmux as the
fallback multiplexer. The entrypoint says so explicitly on failure.

Closes #906
ryaneggz added a commit that referenced this pull request Aug 31, 2026
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.

* task: stop baking the default harnesses into the sandbox image

PR #903 wired provision-harnesses.sh into the boot path but shipped it behind
ARG BAKE_HARNESSES=true, so every default harness was already present when the
provisioner ran and the install path never executed. All four defects that PR's
audit found lived in code a green CI run and a normal boot both skip.

Delete the bake rather than flip its default: remove ARG BAKE_HARNESSES, ARG
AGENTS, the PKG map and the $AGENTS loop in `base`, and the gated pi install in
`home`. A build arg that can re-bake is a dormant path that would restore both
the shadowed /usr/lib/node_modules copy and the untested boot install.

Make the install path CI-visible, since it is now load-bearing on every boot:

- The boot smoke asserts the outcome — each default harness resolves under
  NPM_USER_PREFIX via `type -P`, is owned by the reconciled sandbox uid, and
  prints its own version — and refuses to pass when the catalog reports no
  defaults. It boots on a fresh home volume, so this runs real npm work.
- verify-sandbox-image.sh gains the negative: reading the catalog out of the
  image itself, no kind:"default" harness may be installed.
- start_period goes 300s -> 600s in both compose files to cover the install,
  and the boot-guard probe now derives the smoke deadline from the healthcheck
  window instead of pinning a literal that a start_period bump could invert.
- The probe and unit assertions invert from "the bake is gated" to "no default
  harness package appears in the Dockerfile", reading the package names out of
  installArgv so they cannot drift from the catalog.

cc-safety-net stays baked. Opt-in INSTALL_* harnesses are untouched.

Costs this accepts, documented in installation.md: a first boot on a fresh home
mount needs network and runs 60-180s longer; an offline first boot yields a
usable shell with no agent CLIs; ~/.npm now lives in the home mount.

Closes #904

* task: cite the measured first-boot provisioning time

* task: move herdr and cloudflared out of the image into the tool catalog

Per the ownership boundary — the in-sandbox CLI provisions harnesses and tools —
herdr and cloudflared are tools, so the image should not carry them. #905 did
this for the default harnesses; this does it for the default tools.

The obvious template does not work. #897's tailscale entry root-installs to
/usr/local/bin, and commands/tool.ts:309 passes stdio:"inherit", so
local-target.ts:113-116 selects the INTERACTIVE branch — plain `sudo --`, no
-n. /etc/sudoers.d/sandbox grants `sandbox ALL=(ALL) ALL` with no NOPASSWD, so
`oh tool install <root tool>` hangs on a password prompt no agent can answer.
Verified in a running sandbox: `sudo -n -- true` → "a password is required".
(#897's tailscale has the same defect; flagged there, not fixed here.)

So install to ~/.local/bin as the sandbox user instead, the same correction
#900 made for the harnesses. No sudo, survives container recreation in the home
mount, and upgradeable in place by a running sandbox.

- ToolKind gains "default". herdr 0.7.4 and cloudflared 2026.8.2 become
  kind:"default", installUser:"sandbox", with per-arch pinned URLs and
  sha256 verification into $NPM_USER_PREFIX/bin. Checksums measured by
  downloading both arches, not copied from anywhere.
- provision-harnesses.sh generalizes over both catalogs and becomes
  provision-defaults.sh (OH_PROVISION_DEFAULTS, timeout 180s → 240s). It
  dies rather than reporting success when neither catalog yields a default.
- The Dockerfile loses the herdr RUN, ARG HERDR_VERSION, and the whole
  cloudflared apt block — with it the bookworm-suite workaround that existed
  only because Cloudflare publishes no trixie suite. Docker's is now the only
  third-party apt source.
- Both oracles generalize: verify-sandbox-image.sh rejects a baked default
  harness OR tool, reading each catalog out of the image; the boot smoke
  asserts every default in both catalogs resolves under NPM_USER_PREFIX,
  is owned by the sandbox uid, and prints a version.
- The herdr version+checksum pin moves from the Dockerfile to the catalog,
  and herdr-default.test.ts follows it.

Costs, documented in installation.md: an offline first boot on a fresh home
mount now has no herdr, so `oh shell` lands in a plain shell with tmux as the
fallback multiplexer. The entrypoint says so explicitly on failure.

Closes #906

* task: remove optional-harness build args and make the CLI the install path

#905 and #907 moved the default harnesses and tools out of the image but left
the four optional harnesses behind. The boundary — inside the sandbox the CLI
provisions harnesses and tools — has no carve-out for optional ones.

They were not merely leftover. opencode, grok-build, and hermes are all
installUser:"root", and harness.ts:256 installs with stdio:"inherit", so
local-target.ts selects the INTERACTIVE branch: plain `sudo --`, no -n.
/etc/sudoers.d/sandbox has no NOPASSWD, so `oh harness install opencode` hangs
on a password prompt no agent can answer. The build arg was the only working
path, which is why the Dockerfile blocks could not simply be deleted.

All four relocate to the sandbox user, verified by reading the upstream
installers rather than guessing: opencode takes an npm --prefix like
claude-code; grok's installer honours GROK_BIN_DIR; hermes honours
HERMES_INSTALL_DIR and its get_command_link_dir() already picks ~/.local/bin
for a non-root install; deepagents was already sandbox-installed via uv. So no
sudoers change is needed and no security posture moves.

- Delete all four ARG/RUN pairs, the compose build.args block, and the dead
  /opt/grok-build and /usr/local/lib/hermes-agent chowns. INSTALL_HERMES keeps
  its RUNTIME life — link-providers.sh vendors the Hermes skill pack from it
  and entrypoint.sh wires auth.json — so only its build-arg role goes.
- Remove `buildArg` from HarnessEntry entirely. It was dead metadata: declared,
  set four times, read by nothing. tool-catalog-boundary.sh already banned the
  same field in the tool catalog.
- provision-defaults.sh now reads the full catalog and also installs any
  non-default entry whose install.<key> is true, so declared intent survives a
  fresh home mount. isInstallFlagEnabled already reads oh.json, so this needs
  no new env plumbing.
- verify-sandbox-image.sh widens to "no harness of any kind is baked", and
  gains the inverse for tools: every kind:"baked-in" tool must be present, or
  the check passes on an image missing everything.
- sandbox-compatibility.yml's optional-installer job loses its subject. It now
  boots the image and runs `oh harness install` for each optional harness,
  asserting the binary lands under /home/sandbox/.local — the path operators
  actually use, instead of one that no longer exists.

harness.test.ts had a case asserting `cmd === "sudo"`, codifying the very
defect this fixes. It now asserts no install shells out to sudo at all.

Closes #908

* fix: retry an optional-harness install once before failing the job

The new compatibility job reaches four third-party endpoints. Hermes' own
installer hard-fails the whole install when its internal `npm install` step
blips, which took the job down on a commit that was correct — the rerun passed
unchanged, on the same SHA.

A vendor's transient error must not block this repo's merges. One retry absorbs
it. The contract is unchanged: a genuine break — wrong user, wrong path, a sudo
prompt — fails both attempts and still fails the job.

The probe now asserts both halves, so neither the retry nor the hard failure
after it can be dropped silently.

* fix(test): drop a template-literal scan that fired on catalog order

The source-level regex I added in #906 cannot distinguish a JS backtick from a
backtick inside prose — notInstallableReason has several. It passed only because
every ${...} in the catalog happened to precede the first prose backtick.
Adding a tool below them flips it to a false failure, which is exactly what
happened on the #858 branch.

The per-token ban, with the bash -lc body exempted, covers what is actually
checkable.
ryaneggz added a commit that referenced this pull request Aug 31, 2026
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.

* task: stop baking the default harnesses into the sandbox image

PR #903 wired provision-harnesses.sh into the boot path but shipped it behind
ARG BAKE_HARNESSES=true, so every default harness was already present when the
provisioner ran and the install path never executed. All four defects that PR's
audit found lived in code a green CI run and a normal boot both skip.

Delete the bake rather than flip its default: remove ARG BAKE_HARNESSES, ARG
AGENTS, the PKG map and the $AGENTS loop in `base`, and the gated pi install in
`home`. A build arg that can re-bake is a dormant path that would restore both
the shadowed /usr/lib/node_modules copy and the untested boot install.

Make the install path CI-visible, since it is now load-bearing on every boot:

- The boot smoke asserts the outcome — each default harness resolves under
  NPM_USER_PREFIX via `type -P`, is owned by the reconciled sandbox uid, and
  prints its own version — and refuses to pass when the catalog reports no
  defaults. It boots on a fresh home volume, so this runs real npm work.
- verify-sandbox-image.sh gains the negative: reading the catalog out of the
  image itself, no kind:"default" harness may be installed.
- start_period goes 300s -> 600s in both compose files to cover the install,
  and the boot-guard probe now derives the smoke deadline from the healthcheck
  window instead of pinning a literal that a start_period bump could invert.
- The probe and unit assertions invert from "the bake is gated" to "no default
  harness package appears in the Dockerfile", reading the package names out of
  installArgv so they cannot drift from the catalog.

cc-safety-net stays baked. Opt-in INSTALL_* harnesses are untouched.

Costs this accepts, documented in installation.md: a first boot on a fresh home
mount needs network and runs 60-180s longer; an offline first boot yields a
usable shell with no agent CLIs; ~/.npm now lives in the home mount.

Closes #904

* task: cite the measured first-boot provisioning time

* task: move herdr and cloudflared out of the image into the tool catalog

Per the ownership boundary — the in-sandbox CLI provisions harnesses and tools —
herdr and cloudflared are tools, so the image should not carry them. #905 did
this for the default harnesses; this does it for the default tools.

The obvious template does not work. #897's tailscale entry root-installs to
/usr/local/bin, and commands/tool.ts:309 passes stdio:"inherit", so
local-target.ts:113-116 selects the INTERACTIVE branch — plain `sudo --`, no
-n. /etc/sudoers.d/sandbox grants `sandbox ALL=(ALL) ALL` with no NOPASSWD, so
`oh tool install <root tool>` hangs on a password prompt no agent can answer.
Verified in a running sandbox: `sudo -n -- true` → "a password is required".
(#897's tailscale has the same defect; flagged there, not fixed here.)

So install to ~/.local/bin as the sandbox user instead, the same correction
#900 made for the harnesses. No sudo, survives container recreation in the home
mount, and upgradeable in place by a running sandbox.

- ToolKind gains "default". herdr 0.7.4 and cloudflared 2026.8.2 become
  kind:"default", installUser:"sandbox", with per-arch pinned URLs and
  sha256 verification into $NPM_USER_PREFIX/bin. Checksums measured by
  downloading both arches, not copied from anywhere.
- provision-harnesses.sh generalizes over both catalogs and becomes
  provision-defaults.sh (OH_PROVISION_DEFAULTS, timeout 180s → 240s). It
  dies rather than reporting success when neither catalog yields a default.
- The Dockerfile loses the herdr RUN, ARG HERDR_VERSION, and the whole
  cloudflared apt block — with it the bookworm-suite workaround that existed
  only because Cloudflare publishes no trixie suite. Docker's is now the only
  third-party apt source.
- Both oracles generalize: verify-sandbox-image.sh rejects a baked default
  harness OR tool, reading each catalog out of the image; the boot smoke
  asserts every default in both catalogs resolves under NPM_USER_PREFIX,
  is owned by the sandbox uid, and prints a version.
- The herdr version+checksum pin moves from the Dockerfile to the catalog,
  and herdr-default.test.ts follows it.

Costs, documented in installation.md: an offline first boot on a fresh home
mount now has no herdr, so `oh shell` lands in a plain shell with tmux as the
fallback multiplexer. The entrypoint says so explicitly on failure.

Closes #906

* task: remove optional-harness build args and make the CLI the install path

#905 and #907 moved the default harnesses and tools out of the image but left
the four optional harnesses behind. The boundary — inside the sandbox the CLI
provisions harnesses and tools — has no carve-out for optional ones.

They were not merely leftover. opencode, grok-build, and hermes are all
installUser:"root", and harness.ts:256 installs with stdio:"inherit", so
local-target.ts selects the INTERACTIVE branch: plain `sudo --`, no -n.
/etc/sudoers.d/sandbox has no NOPASSWD, so `oh harness install opencode` hangs
on a password prompt no agent can answer. The build arg was the only working
path, which is why the Dockerfile blocks could not simply be deleted.

All four relocate to the sandbox user, verified by reading the upstream
installers rather than guessing: opencode takes an npm --prefix like
claude-code; grok's installer honours GROK_BIN_DIR; hermes honours
HERMES_INSTALL_DIR and its get_command_link_dir() already picks ~/.local/bin
for a non-root install; deepagents was already sandbox-installed via uv. So no
sudoers change is needed and no security posture moves.

- Delete all four ARG/RUN pairs, the compose build.args block, and the dead
  /opt/grok-build and /usr/local/lib/hermes-agent chowns. INSTALL_HERMES keeps
  its RUNTIME life — link-providers.sh vendors the Hermes skill pack from it
  and entrypoint.sh wires auth.json — so only its build-arg role goes.
- Remove `buildArg` from HarnessEntry entirely. It was dead metadata: declared,
  set four times, read by nothing. tool-catalog-boundary.sh already banned the
  same field in the tool catalog.
- provision-defaults.sh now reads the full catalog and also installs any
  non-default entry whose install.<key> is true, so declared intent survives a
  fresh home mount. isInstallFlagEnabled already reads oh.json, so this needs
  no new env plumbing.
- verify-sandbox-image.sh widens to "no harness of any kind is baked", and
  gains the inverse for tools: every kind:"baked-in" tool must be present, or
  the check passes on an image missing everything.
- sandbox-compatibility.yml's optional-installer job loses its subject. It now
  boots the image and runs `oh harness install` for each optional harness,
  asserting the binary lands under /home/sandbox/.local — the path operators
  actually use, instead of one that no longer exists.

harness.test.ts had a case asserting `cmd === "sudo"`, codifying the very
defect this fixes. It now asserts no install shells out to sudo at all.

Closes #908

* fix: retry an optional-harness install once before failing the job

The new compatibility job reaches four third-party endpoints. Hermes' own
installer hard-fails the whole install when its internal `npm install` step
blips, which took the job down on a commit that was correct — the rerun passed
unchanged, on the same SHA.

A vendor's transient error must not block this repo's merges. One retry absorbs
it. The contract is unchanged: a genuine break — wrong user, wrong path, a sudo
prompt — fails both attempts and still fails the job.

The probe now asserts both halves, so neither the retry nor the hard failure
after it can be dropped silently.

* fix(test): drop a template-literal scan that fired on catalog order

The source-level regex I added in #906 cannot distinguish a JS backtick from a
backtick inside prose — notInstallableReason has several. It passed only because
every ${...} in the catalog happened to precede the first prose backtick.
Adding a tool below them flips it to a false failure, which is exactly what
happened on the #858 branch.

The per-token ban, with the bash -lc body exempted, covers what is actually
checkable.

* task: retire the DeepAgents harness

deepagents-cli is deprecated upstream. Its own --version output now leads with:

  DeprecationWarning: deepagents-cli is deprecated and will not receive further
  releases. Use the `managed-deepagents` package (uv tool install
  managed-deepagents, then mda) instead.

Following it is not a rename — managed-deepagents ships a different binary
(mda), so binary, verifyArgv, the banner tile, and the docs would all move.
Operator ruling: retire the entry rather than chase a product that is moving
away from a local CLI.

Removed: the catalog entry; install.deepagents from OhConfig, its default, its
validation loop and its OH_CONFIG_FIELDS row; the config-render put(); the
env-file INSTALL_FIELDS/CONFIG_FIELD_BY_ENV_KEY entries; the init wizard option
and its ENV_TO_CONFIG mapping; the banner tile and shortcut; install.sh and
migrate-harness-yaml.sh entries; docs/harnesses/deepagents.md and every
reference to it; the oh.json key.

Kept deliberately:
- INSTALL_DEEPAGENTS in config-render's RETIRED_KEYS, so anything that tries to
  render it again fails loudly instead of emitting a dead variable.
- INSTALL_DEEPAGENTS in the two probes that assert no harness build arg returns
  to the Dockerfile. Dropping it there would let the arg come back unnoticed.

`oh config set install.deepagents true` now exits 1 with `unknown field`,
because the OH_CONFIG_FIELDS row is gone. A stale key already present in an
oh.json is ignored rather than rejected — that is the pre-existing behaviour for
any unrecognised install.* key, not something this change introduces.

Closes #910
…cale-t3-mobile

# Conflicts:
#	.oh/cli/src/__tests__/tool-catalog.test.ts
#	.oh/cli/src/lib/oh-config.ts
#	.oh/evals/RESULTS.md
#	.oh/scripts/install.sh
#	docs/configuration.md
@ryaneggz
ryaneggz merged commit a679a04 into development Aug 31, 2026
4 checks passed
@ryaneggz
ryaneggz deleted the feat/858-tailscale-t3-mobile branch August 31, 2026 21:05
@ryaneggz ryaneggz mentioned this pull request Sep 1, 2026
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