release: v0.6.0 - #936
Merged
Merged
Conversation
The runner extracts `# tier:`, `# source:`, and `# desc:` with a fixed grep, so a probe missing `# source:` leaves that column empty in RESULTS.md and loses the only record of which lesson it closes. Rebased across the one-door migration, which moved the target set: the header written for `env-schema-parity.sh` now belongs to its successor `config-schema-parity.sh`, `oh-init-headless-config.sh` picks up the retarget to `oh.json`, and `oh-config-surfaces.sh` — added by #887 after this branch opened — was the fifth probe still missing one. Closes #889
/delegate steps 4 and 5b instructed TaskCreate/TaskUpdate from .oh/skills/, the canonical pack symlinked into .claude, .codex and .pi. Codex and Pi never had those tools, and Claude Code 2.1.233 stopped providing them by default on current models, so the step was a silent no-op: the wave graph degraded to prose held in a context window that /spec execute later compacts. Steps 4 and 5b now write delegate-graph.json and delegate-log.txt under .oh/tasks/<slug>/, beside but never overwriting the Advisor-owned prd.json and progress.txt, and a resumed run re-runs only unfinished tasks. The fix is provider-neutral and needs no environment flag. Add skills-task-tool-coupling.sh, a two-sided tier-A invariant: a canonical skill may instruct the task tools only while the sandbox enables them, and the sandbox may enable them only while a skill needs them. Both failure directions were exercised before commit. Also drops a dangling 'skip to Step 9' in the rewritten block; the skill has eight steps. Closes #886
* task: collapse sandbox storage to one $HOME mount Replace the eleven per-tool named volumes with a single mount at /home/sandbox, selectable as a Docker-managed volume (<name>_workspace) or a host bind via storage.homePath in oh.json. The image's home is moved to /opt/home-seed at build time so an empty named volume never auto-copies; entrypoint's seed_home restores it into either mount kind with cp -a -n, which also backfills new dotfiles on an image upgrade without clobbering operator state. Retire the projectRoot/OH_PROJECT_ROOT config knob: the checkout is fixed at /home/sandbox/harness, nested inside the home mount. Replace the UID-sync 'find -xdev' with an explicit $OH_PROJECT_ROOT prune. -xdev only pruned the checkout because /home/sandbox was the container overlay; once it is a mount they share a device. * task: retarget the herdr image-docs persistence assertion at the single home mount * task: address audit findings on the single-$HOME-mount change seed_home no longer uses 'cp -a -n'. That form skips existing regular files but still rewrites the mode, owner and mtime of directories the destination already has, so every boot relaxed ~/.ssh from 0700 to the image's 0755 and reset the other pre-existing home directories likewise. It now walks the seed and copies only entries the mount lacks, creating a missing directory with the seed's own metadata and never touching an existing path. It also returns non-zero on a real copy failure instead of swallowing it, and the caller warns. oh destroy branches on storage.homePath rather than appending a correction: with a host bind, nothing is deleted and sign-in does not start over, so the volume list and that warning are wrong to print. The volume list is now project-prefixed to match 'docker volume ls'. Reject reserved host paths for storage.homePath. The ownership repair is now an unbounded recursive chown over the mount, so '/', '/home' or '/usr' would hand the sandbox uid a host tree. Fix the migration recipe: 'docker cp <name>:/home/sandbox <dir>' lands the copy at <dir>/sandbox when <dir> exists. Use the trailing '/.' form and drop the checkout copy that docker cp reads through the bind mount. Document that storage.homePath is invisible to VS Code 'Reopen in Container', which would otherwise give a second, silently diverging home. Harden oh-home-mount.sh with the directory-metadata and copy-failure cases that let the original bug through, plus the missing rm -rf assertion; scope the -xdev ban to lines mentioning /home/sandbox. Refresh the stale project-root seam section in .oh/README.md, which still described the removed knob and cited the deleted probe. * task: drop local agent memory and cron runtime state committed by mistake * task: seed the home mount per top-level entry, not per file A live boot on a real image caught the previous form: walking the whole seed tree and copying file by file spends its time in the uv cache under ~/.cache/uv, and it aborted partway through with EEXIST, leaving a home with four of sixteen entries. seed_home now copies each top-level entry the mount lacks with a single 'cp -a', and never touches one it already has. Verified on a live boot: a clean volume gets all sixteen entries in 7s, ~/.ssh ends at 0700, and an operator edit to ~/.zshrc plus a 0700 ~/.ssh both survive a restart. The trade is explicit and documented: a new file inside a directory the mount already has is not merged in. That matches what the per-tool volumes did, while a new top-level entry from an image upgrade still arrives. oh-home-mount.sh asserts the contract in both directions and now rejects the two wrong implementations - 'cp -a -n' and the whole-tree walk - rather than only the first. Bake ~/.ssh at 0700 in the image so the seeded mode is right before the entrypoint's chmod rather than because of it. * task: ignore per-session agent memory and cron runtime state .oh/memory/ and .oh/crons/ hold local agent scratch — session logs, prompt-miner dumps, a cron pid and log. Nothing in either is tracked on any branch, but neither was ignored, so a `git add -A` sweeps 37 files of operator session state into the commit. It happened twice while building this branch. Ignore both, following the wiki-corpus convention already in this file: local by default, `git add -f` for anything worth keeping.
* task: collapse sandbox storage to one $HOME mount Replace the eleven per-tool named volumes with a single mount at /home/sandbox, selectable as a Docker-managed volume (<name>_workspace) or a host bind via storage.homePath in oh.json. The image's home is moved to /opt/home-seed at build time so an empty named volume never auto-copies; entrypoint's seed_home restores it into either mount kind with cp -a -n, which also backfills new dotfiles on an image upgrade without clobbering operator state. Retire the projectRoot/OH_PROJECT_ROOT config knob: the checkout is fixed at /home/sandbox/harness, nested inside the home mount. Replace the UID-sync 'find -xdev' with an explicit $OH_PROJECT_ROOT prune. -xdev only pruned the checkout because /home/sandbox was the container overlay; once it is a mount they share a device. * task: retarget the herdr image-docs persistence assertion at the single home mount * task: address audit findings on the single-$HOME-mount change seed_home no longer uses 'cp -a -n'. That form skips existing regular files but still rewrites the mode, owner and mtime of directories the destination already has, so every boot relaxed ~/.ssh from 0700 to the image's 0755 and reset the other pre-existing home directories likewise. It now walks the seed and copies only entries the mount lacks, creating a missing directory with the seed's own metadata and never touching an existing path. It also returns non-zero on a real copy failure instead of swallowing it, and the caller warns. oh destroy branches on storage.homePath rather than appending a correction: with a host bind, nothing is deleted and sign-in does not start over, so the volume list and that warning are wrong to print. The volume list is now project-prefixed to match 'docker volume ls'. Reject reserved host paths for storage.homePath. The ownership repair is now an unbounded recursive chown over the mount, so '/', '/home' or '/usr' would hand the sandbox uid a host tree. Fix the migration recipe: 'docker cp <name>:/home/sandbox <dir>' lands the copy at <dir>/sandbox when <dir> exists. Use the trailing '/.' form and drop the checkout copy that docker cp reads through the bind mount. Document that storage.homePath is invisible to VS Code 'Reopen in Container', which would otherwise give a second, silently diverging home. Harden oh-home-mount.sh with the directory-metadata and copy-failure cases that let the original bug through, plus the missing rm -rf assertion; scope the -xdev ban to lines mentioning /home/sandbox. Refresh the stale project-root seam section in .oh/README.md, which still described the removed knob and cited the deleted probe. * task: drop local agent memory and cron runtime state committed by mistake * task: seed the home mount per top-level entry, not per file A live boot on a real image caught the previous form: walking the whole seed tree and copying file by file spends its time in the uv cache under ~/.cache/uv, and it aborted partway through with EEXIST, leaving a home with four of sixteen entries. seed_home now copies each top-level entry the mount lacks with a single 'cp -a', and never touches one it already has. Verified on a live boot: a clean volume gets all sixteen entries in 7s, ~/.ssh ends at 0700, and an operator edit to ~/.zshrc plus a 0700 ~/.ssh both survive a restart. The trade is explicit and documented: a new file inside a directory the mount already has is not merged in. That matches what the per-tool volumes did, while a new top-level entry from an image upgrade still arrives. oh-home-mount.sh asserts the contract in both directions and now rejects the two wrong implementations - 'cp -a -n' and the whole-tree walk - rather than only the first. Bake ~/.ssh at 0700 in the image so the seeded mode is right before the entrypoint's chmod rather than because of it. * task: ignore per-session agent memory and cron runtime state .oh/memory/ and .oh/crons/ hold local agent scratch — session logs, prompt-miner dumps, a cron pid and log. Nothing in either is tracked on any branch, but neither was ignored, so a `git add -A` sweeps 37 files of operator session state into the commit. It happened twice while building this branch. Ignore both, following the wiki-corpus convention already in this file: local by default, `git add -f` for anything worth keeping. * task: slim the sandbox image by ~540 MB Stop shipping build caches inside the baked home seed, and stop shipping the seed twice. The home seed measured 358 MB, of which 103 MB was pure build cache: ~/.npm (31 MB, left by the pi npm install) and ~/.cache/uv (72 MB, left by provision-python.sh). Neither is read at runtime -- the entrypoint's repair_home_mount_ownership install -d's the uv cache on every boot, and npm recreates its own. Both purges fold into the RUN that produces them. RUN mv /home/sandbox /opt/home-seed moved data across a layer boundary, so the image stored the whole seed twice. The Dockerfile is now three stages: base (through the sandbox user and the UV_* env), home (everything that writes into /home/sandbox), and final, which brings the seed in with COPY --from=home --chown=sandbox:sandbox. The final stage also removes the useradd skel files that the old wholesale mv used to take with it, preserving the empty-home invariant that keeps an empty named volume from auto-copying. Runtime NPM_USER_PREFIX and UV_* still point at /home/sandbox, so pi self-updates and uv tool install keep persisting into the mount. .dockerignore now excludes .pnpm-store and the untracked .pi build outputs, which were pulling ~2 GB into every local build context. Two oracles move with the mechanism: oh-home-mount.sh pinned the literal RUN mv spelling and now asserts the invariant (seed staged at /opt/home-seed, empty home, no harness/), and sandbox-base-image.test.ts end-anchored the FROM line, which a stage alias necessarily breaks. Measured with docker system df -v: 4.0 GB -> 3.46 GB. * fix: close four oracle gaps an adversarial audit found in the seed slimming The Dockerfile restructure held up under an entry-by-entry comparison of the two built images -- 29,992 seed entries, zero differences in type, mode, or owner, and a path delta that is exactly the intended cache purge. The defects were all in the guards. image-seed-hygiene.sh compared textual line numbers in a file where instruction order is not execution order, so it passed a Dockerfile that still shipped both caches: delete the purges from the home stage and add them as the first instruction of the final stage, and the line-number check is satisfied while the seed is copied from an unpurged stage. It now parses stages, anchors on the staging instruction itself rather than any mention of /opt/home-seed, resolves which stage the seed is copied from, and requires a purge inside that stage. The single-stage RUN mv form still works, and the previously fragile anchor no longer false-positives on an earlier ENV referencing the same path. oh-home-mount.sh accepted COPY --from=base (whose home is bare useradd skel, so every baked dotfile would be lost) and COPY --from=alpine:latest. The source must now be a stage declared in the same Dockerfile, and not base. sandbox-base-image.test.ts stopped pinning anything once there were three FROM lines -- any one matching satisfied it, so FROM ubuntu:24.04 AS final passed. A new case walks the FROM lines in order and asserts every image is either the pinned base or a stage defined earlier. COPY --from creates its destination at the default 0755 and --chown does not restore a mode, so /opt/home-seed silently went 0700 -> 0755 versus the RUN mv era. Restored, and now asserted by the probe, since nothing caught it. Each fix is mutation-verified: the audit's mutant and all four original negative controls exit 1, the false-positive control stays green, and both bad COPY --from sources are rejected by both probes. * task: refresh eval scoreboard after merge
* 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: 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: 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: 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: 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
* feat(cli): support T3 Code mobile access over Tailscale (#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 * fix(cli): use the default tailscaled socket for T3 mobile (#858) 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. * test(cli): guard the tailscale socket-directory step against drift (#858) 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. * fix(cli): install Tailscale as the sandbox user, not root (#858) 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. * fix(test): restore the newline escape lost resolving the tool-catalog merge * eval: refresh the scoreboard after merging development
The four correctness gates prove a change works. None can fail a change that works and is twice the size it needed to be. Gate 5 asks whether the diff can be smaller and still satisfy every acceptance criterion, and fails the audit while the answer is yes. Signals come from two new implementation-gates.sh modes: slop-metrics reports net lines, per-function CCN on changed TypeScript via uvx lizard, and a disclosed branch-point proxy for shell; simplicity-round reads the caller's round record. An unresolvable lizard reports unavailable rather than an empty clean result. The loop terminates by construction, not by agreement: a finding without a concrete smaller alternative is not a finding, and the loop ends at a cap of three rounds or on a round that fails to reduce netAdded, passing with SIMPLICITY-RESIDUAL for the operator.
An unrecognized first token now routes to a new `ship` node instead of printing usage, so `/spec <plan-path>` runs plan then execute through to a ready-for-review PR. `ship` composes the existing nodes and owns no build mechanics; `spec-family-contract` fails if it grows a build literal, if the dispatcher stops routing the default, or if ship.md stops stating how it treats the commitment gate. The gate itself survives: handing in an approved plan file is the approval, and a bare topic with no plan file still stops after `plan`. Closes #914 Submitted-by: kre8mymedia@gmail.com
…l-impact ledger (#916) * skill: ingest WikiSkill (arXiv 2608.27454) into the wiki corpus * skill: add wiki lint related-slug check and generate the index from the tracked set * skill: add kind: source|pattern to the wiki schema and a --patterns query mode * skill: add /wiki compile and the skill-impact ledger * skill: make /builder consult wiki patterns and record proposals in the ledger * skill: record verdicts in the ledger and route retro lessons through /wiki compile * skill: add wiki co-evolution probes, CB-005, and the changelog entries * skill: compile the first wiki pattern from this session's lesson * skill: record SI-0001 in the skill-impact ledger * skill: scope the ledger probe to the append region and exercise its oracles * skill: fix the ledger record parser so appends do not read as mutations * skill: score CB-005 from the first real run of the lesson-to-skill chain * skill: refresh the eval scoreboard * skill: compile four patterns from the session retro via delegated maintainer * skill: close the compile procedure ambiguities the delegated run surfaced * skill: refresh the eval scoreboard * skill: re-score CB-005 from the delegated compile run * skill: mint the two retro-nominated probes and require fault injection * skill: restore the probe contract sections and shorten two changelog entries * skill: refresh the eval scoreboard * skill: regenerate the eval scoreboard after the merge
A cron fire, a detached Advisor, or a background agent that is blocked on an operator decision has nowhere to put it. The GitHub path is proven broken: on #799 the automation's gh identity is the operator's own account, so seven comments produced zero notifications and a one-line kill-switch took ten days. /escalate is one deterministic script with no live agent and no attached terminal in the loop. --needs is required, because an escalation that names no decision is a log line. --key suppresses a repeat for 12h so a looping session cannot train the operator to ignore the channel. Channel health is checked before every send. An unavailable channel is a no-op, not a raised error -- a dead channel must not take down the session reporting through it. But a no-op is still a non-delivery, so the reason is printed, the JSON carries ok:false, and every attempt appends to .oh/logs/escalations.jsonl. Silence is the failure this closes. .oh/logs/ is new: gitignored by default with a tracked README, matching the .oh/tasks/ idiom, for records that must outlive the session that wrote them.
Root AGENTS.md draws the line already: a nested AGENTS.md belongs to directories whose contents are produced apart from the root context, and everything else uses a README. .oh/logs/ is that shape -- unattended sessions write to it without the root context loaded, and the guide's reader is the next agent deciding whether a record belongs there. Renames the guide, adds the CLAUDE.md provider symlink, un-ignores both, and names the fourth directory in the root sentence. The escalate-contract probe now asserts the symlink target rather than only the guide's presence. Adding the directory to that sentence pushed AGENTS.md to 9507 B against a 9500 B budget and the context-tier ratchet caught it, so the sentence is compressed rather than the budget raised.
* task: scaffold compose-env-boundary task Four-file contract: - prd.md: 11 user stories - prd.json: schemaVersion 1, branchName task/920-compose-env-boundary - prompt.md: the rendered single-owner task prompt - progress.txt: empty header Tracks #920. PRD generated by /prd; converted by /ralph. Submitted-by: Claude * task: make the CLI the only install/config surface Move eleven settings out of the compose environment: block into oh.json, read inside the container through the oh CLI. A value belongs in compose only if a process outside the sandbox — or the entrypoint before the control plane is readable — must act on it. - entrypoint.sh gains oh_config/oh_config_truthy over `oh config show`, and loses the duplicate agent-browser and Tailscale installers; the tool catalog is now the sole owner of both pins and Tailscale's two checksums. - The Hermes wiring keys off `command -v hermes` instead of INSTALL_HERMES, so it runs identically in both sandbox flavors. Flavor B previously carried none of the flavor-A-only keys, leaving all of it dead there. - The sshd overlay keeps its ports: and loses its environment:; sshd's mode comes from access.ssh* in oh.json. - Retire docker-compose.hermes-dashboard.yml and its published 127.0.0.1:9119. - config-render.ts stops rendering ten keys and adds each to RETIRED_KEYS, so a re-added put() throws; tools/catalog.ts drops entrypointGuard. - cron-runtime.ts resolves the agent binary from CRON_AGENT_BIN, then oh.json, then "claude". - tool-catalog-boundary and tailscale-tool-boundary are inverted to assert the post-epic boundary; every zero-exposure assertion is kept. Every oh.json field survives — only its .env projection is gone. Tracks #920. Submitted-by: Claude * task: detect the sandbox flavor instead of declaring it Replace the OH_IMAGE_ONLY compose flag with a runtime check. Both conditions are load-bearing: mountpoint alone misreads an empty bind mounted straight at the project root as a checkout, and the .oh/ test alone would send a seeded no-bind volume through the host-UID sync on its second boot. The detected mode is logged on both paths, so a wrong detection shows up in `oh logs` rather than silently. `.oh/.image-seeded` is now gitignored, so a misdetection can never leave an untracked marker in a real checkout. With the flag gone the two compose files' environment: blocks are byte-identical; a new tier-A probe, compose-env-boundary.sh, holds that across every compose file and overlay: no INSTALL_*, no OH_IMAGE_ONLY, and every key either rendered by config-render.ts or one of four documented literals. oh-image-only-deploy.sh drops the three assertions that only required documentation prose to contain or omit a string; every behavioral assertion is kept and the gate check now targets detection. Tracks #920. Submitted-by: Claude * task: record compose-env-boundary evidence and eval result Story graph complete (11/11), /eval clean at this HEAD, and evidence.md answers back to the approved plan: what improved, what was built, five divergences, and what stays unverified without a Docker daemon. Tracks #920. Submitted-by: Claude * task: refresh the eval scoreboard on the merged tree Regenerated after merging development rather than resolving the generated RESULTS.md by hand. 117 probes, runner exit 0, no REGRESSION/TIMEOUT/ERROR rows. Tracks #920. Submitted-by: Claude * task: key skills-vendored on the hermes binary, not INSTALL_HERMES The probe drove link-providers.sh with INSTALL_HERMES=true and asserted the Hermes skill symlink appeared. #920 replaced that flag with a `command -v hermes` check, so on a CI runner with no hermes the link is correctly never created and the assertion failed. It now stubs hermes on a bare PATH inside the clean clone and asserts both directions: absent with no binary, present with one. Verified in an isolated clone that each direction flips when the gate is mutated to `true` / `false` — the probe clones committed state, so an in-place working-tree mutation cannot reach it. Tracks #920. Submitted-by: Claude * task: strip explanatory comments from .devcontainer Comments are a second, unverified description of behavior. Remove every explanatory comment from .devcontainer/ (Dockerfile, all compose files, entrypoint.sh, the two client scripts) and the stale .dockerignore block that still named the retired OH_IMAGE_ONLY flag. Kept only machine-read comment-shaped data: the shebangs, the '# >>> name >>>' fences that oh-home-mount.sh, oh-image-only-deploy.sh and entrypoint.test.ts extract functions from, and the header written into the generated sshd config. Re-anchor four assertions in three tests that sliced entrypoint.sh on comment prose so they slice on code instead; one of them (entrypoint.test.ts reconBranch) had been anchored on the pre-#920 'elif [ -d "$HARNESS_DIR" ]' line and was passing vacuously on a whole-block slice.
* task: retire the Prime Agent harness * task: preserve retired Prime Agent state ignores * task: refresh the eval scoreboard after merging development
* task: retire automated /spec agent handoff /spec execute no longer launches a coding agent. The agent that runs it is the single implementation owner from the isolated worktree through the final PR gates; /delegate stays bounded, disjoint fan-out beneath that owner. Retired: the detached tmux Advisor launch, agent-spec-* sessions, tmux pipe-pane logging, Advisor-specific resume/monitoring, automated Herdr workspace/tab/pane creation, and every runner fallback. Task identity and RUNNING state now depend on .oh/tasks/<slug>/ alone. Preserved: worktree isolation, implementation <-> audit, the eval/evidence/ retro/final-PR gates, human merge, Herdr as operator-managed interactive tooling, and tmux for headless infrastructure. Probes: advisor-monitored-loop renamed to spec-single-owner; adds spec-no-agent-handoff, spec-no-advisor-session-coupling, cleanup-no-agent-session-coupling, headless-tmux-preserved. Tracks #928. Submitted-by: Claude * task: cut scope creep and redundant probe machinery from #928 Four simplifications, no coverage lost: 1. Revert the plan-vs-built-reconciliation line-citation renumbering and the updated: bump (pre-existing drift, no acceptance criterion, no probe), which also drops corpus/README.md from the diff entirely. 2. Drop spec-single-owner's agent-spec- marker — spec-no-advisor-session-coupling already scans both files for it. 3. Reword two prohibition sentences so they no longer use the handles the probe bans, replacing a 20-line negation-aware awk matcher with a literal ban. 4. Compress the CHANGELOG from six bullets to four. Tracks #928. Submitted-by: Claude
Adopt ADR #929: the active coding agent is the runtime, skills encode durable roles and procedures, and subagents stay a bounded execution primitive owned by /delegate. Adds /architect as an inline architecture-decision skill and retires the project-authored agent catalog, the /builder agent artifact type, and every stale project-agent reference across skills, docs, provider wiring, and the oh payload manifest. Submitted-by: Claude Code (claude-opus-5)
* task: retire cron worktree isolation Remove the worktree: frontmatter key, .worktrees/cron/ per-fire worktrees, the CRON_WORKTREE export, the fallback-worktree prune/inspect/reap machinery, and their log states. Every cron now fires in the shared root checkout under the id-scoped overlap lock. Tracks #933. Submitted-by: Claude * task: drop the CRON_WORKTREE root seam from audit-run Nothing sets CRON_WORKTREE now that cron worktree isolation is retired. audit-run resolves its root from the script path alone; the root-contract probe runs a fixture-local copy of the script instead of overriding the root through the environment. Tracks #933. Submitted-by: Claude
Closes #926. Make /spec a closed repo-knowledge learning loop: planning recalls tracked knowledge and re-grounds it against current sources before the PRD exists, execution re-grounds against HEAD and derives knowledge invalidation from the actual diff, freshness becomes source-change aware via verified_at, and durable knowledge moves to a tracked .oh/knowledge/ surface with per-machine scratch physically separated. Retired atomically with every consumer: /spec ship, the generated prompt.md, the STATUS: COMPLETE sentinel, mandatory /compact as a semantic stage, the .oh/memory vocabulary, and /wiki lint's orphan and 90-day checks. Twelve probes added (the ten #926 requires plus two minted from the run's retro), each fault-injected. Reconciled with #930, #931, and #934, which merged during the build. Submitted-by: Claude
Promote the [Unreleased] body to [0.6.0], consolidating the duplicated category headings into one block per category, and bump root package.json to 0.6.0 so the release workflow reserves v0.6.0 on the merge to main.
version-parity.sh requires package.json, .oh/cli/package.json, and the CHANGELOG to name one version; publish-cli.yml treats an already-published CLI version as a no-op, so a stale 0.5.1 would ship the old bundle.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
developmenttomainto cut v0.6.0.Merging this pushes
main, which triggers.github/workflows/release.yml: it reserves thev0.6.0tag from rootpackage.json, publishes the GHCR image and the CLI, and publishes the GitHub Release from the## [0.6.0]changelog section.Release prep in this PR
package.json0.5.1→0.6.0.## [Unreleased]body to## [0.6.0] - 2026-08-31, consolidating duplicated category headings into one block per category. All 70 entries preserved verbatim; a fresh empty## [Unreleased]heads the file.Why a minor bump
0.x with breaking changes: this window retires the prime agent, the spec agent handoff, cron worktree isolation,
OH_IMAGE_ONLY, the hermes-dashboard overlay, and the baked-in coding harnesses, and moves repository knowledge to a tracked.oh/knowledge/surface.Scope — 22 commits since v0.5.1
.oh/knowledge/(source/,patterns/,raw/),/spec planrecall and re-grounding, diff-derived knowledge invalidation in/spec execute(feat: make /spec a closed repo-knowledge learning loop #926)./architectadded (FROM feat/929-architect-skill TO development #931).deepagents(task(#910): deepagents-cli is deprecated upstream — catalog points at a retired package #910)./home/sandboxmount (task(#898): collapse sandbox storage to one $HOME mount #898); ~540 MB smaller (task(#900): slim the sandbox image now that $HOME persists #900).oh.jsonbehind theohCLI (task(#920): make the CLI the only install/config surface — empty the compose environment block #920)./escalatefor unattended sessions (skill(#919): an unattended session has no way to reach a human #919, FROM skill/919-escalate TO development #921); T3 Code over Tailscale (feat(cli): support T3 Code mobile access over Tailscale #858, feat(cli): support T3 Code mobile access over Tailscale #897).Full list in
CHANGELOG.md.Verification
development(CI: Harness,CI: Sandbox Boot Guard)..oh/evals/probes/changelog-entry-length.sh— PASS.v0.6.0tag exists;## [0.6.0]section present and matchespackage.json.