Skip to content

FROM task/906-tools-out-of-image TO development - #907

Merged
ryaneggz merged 6 commits into
developmentfrom
task/906-tools-out-of-image
Aug 31, 2026
Merged

FROM task/906-tools-out-of-image TO development#907
ryaneggz merged 6 commits into
developmentfrom
task/906-tools-out-of-image

Conversation

@ryaneggz

Copy link
Copy Markdown
Collaborator

Closes #906

Stacked on #905 (task/904-unbake-harnesses, itself on #903). Retarget as the stack lands.

Why

The operator ruling: "outside the sandbox the CLI is used for provisioning host or sandbox on the host; once inside the sandbox the CLI is for provisioning sandbox with harnesses and tools." herdr and cloudflared are tools. #905 did this for the default harnesses; this does it for the default tools.

cloudflared's bake was also a standing workaround β€” Dockerfile:31-37 pinned Cloudflare's bookworm apt suite on a trixie base because Cloudflare publishes no trixie suite. Moving it to a pinned binary deletes that hack. Docker's is now the only third-party apt source in the image.

The blocker, and why the cited template could not be copied

#897's tailscale entry was the plan's template. It does not work, and the reason is load-bearing:

commands/tool.ts:309 installs with stdio: "inherit", so local-target.ts:113-116 picks the interactive branch β€” plain sudo --, no -n. /etc/sudoers.d/sandbox grants sandbox ALL=(ALL) ALL with no NOPASSWD. Verified in a live sandbox:

$ sudo -n -- true
sudo: a password is required

So oh tool install <any root-installed tool> hangs on a password prompt no agent can answer. This is the S1 risk carried out of #903, and it lands the moment a default tool is root-installed.

#897's tailscale has the same defect (installUser: "root", same code path). Flagged for that PR; not fixed here.

The fix is the same correction #900 made for the harnesses: install to ~/.local/bin as the sandbox user. herdr and cloudflared are single static binaries with no system integration, so nothing needs a root-owned path. This gets no sudo, survival across container recreation, and in-place upgradeability that a root-owned /usr/local/bin copy cannot have.

What changed

  • ToolKind gains "default". herdr 0.7.4 and cloudflared 2026.8.2 become kind: "default", installUser: "sandbox", per-arch pinned URLs, sha256sum -c - into $NPM_USER_PREFIX/bin.
  • provision-harnesses.sh generalizes over both catalogs β†’ provision-defaults.sh (OH_PROVISION_DEFAULTS, timeout 180s β†’ 240s). It dies rather than reporting success when neither catalog yields a default.
  • Dockerfile loses the herdr RUN, ARG HERDR_VERSION, and the entire cloudflared apt block.
  • 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; herdr-default.test.ts follows it.

cc-safety-net stays baked (operator ruling). docker-cli and gh stay baked-in β€” apt packages with system integration, not relocatable single binaries.

Checksums are measured, not asserted

Both binaries were downloaded and hashed, then both install scripts were executed end to end as the sandbox user against a throwaway prefix:

/tmp/tmp.kaKebhd5uy/herdr: OK          β†’ herdr 0.7.4
/tmp/tmp.95hM6UnKO4/cloudflared: OK    β†’ cloudflared version 2026.8.2

Negative test: corrupting the pinned sha gives FAILED / WARNING: 1 computed checksum did NOT match, exit 1, and nothing installed.

Mutation-verified assertions

Six mutations against the new probe, each confirmed to exit 1 β€” plus one control confirmed to stay green:

Mutation Result
re-bake herdr in the Dockerfile caught
re-bake cloudflared in the Dockerfile caught
control: oh-my-zsh cloned from github.com correctly still passes
default tool with installUser: "root" caught
default tool without sha256sum -c - caught
default tool installing to /usr/local caught

The control matters: my first draft matched on the bare host https://github.com, which the Dockerfile legitimately uses to clone zsh plugins. It failed against real content and I narrowed the match to the pinned project path.

Cost this accepts

Worse than #905's, and worth stating plainly: an offline first boot on a fresh home mount has no herdr, and herdr is step 3 of the documented lifecycle. tmux remains, so the sandbox is usable. The entrypoint says so explicitly rather than failing silently:

[entrypoint] WARNING: default provisioning did not complete; run: bash .oh/scripts/provision-defaults.sh
[entrypoint] WARNING: herdr may be unavailable β€” 'tmux' still works as a fallback multiplexer

Verification

EVAL=0 (103 probes, no regressions). npx vitest run 953 passed / 8 failed β€” the known compose-args.test.ts environmental baseline. shellcheck 0, tsc --noEmit 0, npm run build 0. Local docker build and the booted-image checks follow in a comment.

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.
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.
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
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

Copy link
Copy Markdown
Collaborator Author

Verification results

All five defaults provision at boot, on a fresh volume, in CI

Volume "openharness-boot-guard-33351597690_workspace"  Created
sandbox boot smoke: claude-code provisioned at boot -> 2.1.251 (Claude Code)
sandbox boot smoke: codex       provisioned at boot -> codex-cli 0.151.0
sandbox boot smoke: pi          provisioned at boot -> 0.84.4
sandbox boot smoke: herdr       provisioned at boot -> herdr 0.7.4
sandbox boot smoke: cloudflared provisioned at boot -> cloudflared version 2026.8.2 (built 2026-08-14-12:17 UTC)
sandbox boot smoke ok: ... boot-provisioned harness and tool checks

Both negative oracles fired in the same run, against the built image:

ok: no default harness is baked into the image (claude-code codex pi)
ok: no default tool is baked into the image (herdr cloudflared)

Boot cost: 23 seconds (02:45:29 β†’ 02:45:52), against 21s for #905. Adding ~57 MB of binary downloads cost ~2s on a runner. 6/6 checks green.

Local image

docker build succeeds; verify-sandbox-image.sh passes every check. type -P inside the image:

Binary Result
claude, codex, pi, herdr, cloudflared absent
cc-safety-net, gh, docker present

/etc/apt/sources.list.d/ now holds debian.sources, docker.list, github-cli.list β€” cloudflared.list is gone, which is the bookworm-suite workaround actually deleted rather than relocated.

Size β€” a clean measurement this time

Unlike #905, both images here were built from the same checkout, so this is one-variable:

#905 image this PR Delta
du -sx / 1,459,984 KB 1,403,552 KB βˆ’55 MiB
/usr 930,704 KB 873,200 KB βˆ’56 MiB
/opt, /root unchanged unchanged 0

The entire delta is in /usr, exactly where herdr (/usr/local/bin, 19 MB) and cloudflared (/usr/bin, 38 MB) lived. /opt and /root being byte-identical is the check that no build-context drift crept into this comparison β€” the flaw that made #905's top-line number unusable.

Docker's reported layer sum: 2.02 GB β†’ 1.94 GB.

Local checks

EVAL=0 (103 probes, no regressions; oh-config-surfaces is the pre-existing persistent red). npx vitest run 953 passed / 8 failed β€” the known compose-args.test.ts baseline. shellcheck 0, tsc --noEmit 0, npm run build 0.

@ryaneggz
ryaneggz changed the base branch from task/904-unbake-harnesses to development August 31, 2026 20:34
@ryaneggz ryaneggz changed the title FROM task/906-tools-out-of-image TO task/904-unbake-harnesses FROM task/906-tools-out-of-image TO development Aug 31, 2026
development gained 09dfa8c and 0b6dad5, squashes of 61b1801, d91cda3,
801b339 and dfde856. All four are already ancestors of this branch, so
development adds no content this branch lacks.
@ryaneggz
ryaneggz merged commit 744e5c3 into development Aug 31, 2026
6 checks passed
ryaneggz added a commit that referenced this pull request Aug 31, 2026
development's tree at 744e5c3 is byte-identical to cdf7f42, an ancestor of
this branch, so development adds no content this branch lacks.
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
@ryaneggz
ryaneggz deleted the task/906-tools-out-of-image branch August 31, 2026 21:05
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