FROM task/910-retire-deepagents TO development - #911
Merged
Conversation
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
β¦ 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
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.
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.
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
This was referenced Aug 31, 2026
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.
Closes #910
Stacked on #909. This is the fifth level of the stack β see the note at the bottom.
Why
deepagents-cliis deprecated upstream. Its own--versionleads with the notice:Following it would not be a rename β
managed-deepagentsships a different binary (mda), sobinary,verifyArgv, the banner tile, and the docs all move with it. Operator ruling: retire the entry rather than chase a product moving away from a local CLI.Removed
The catalog entry;
install.deepagentsfromOhConfig, its default, its validation loop and itsOH_CONFIG_FIELDSrow; theconfig-renderput(); theenv-fileINSTALL_FIELDS/CONFIG_FIELD_BY_ENV_KEYentries; theoh initwizard option and itsENV_TO_CONFIGmapping; the banner tile and shortcut; theinstall.shandmigrate-harness-yaml.shentries;docs/harnesses/deepagents.mdand every reference across README, docs/README, configuration, installation, quickstart, harnesses/overview and harnesses/hermes; theoh.jsonkey.Kept deliberately
Two references survive on purpose, and removing them would quietly undo work:
INSTALL_DEEPAGENTSinconfig-render'sRETIRED_KEYSβ anything that tries to render it again fails loudly instead of emitting a dead variable.INSTALL_DEEPAGENTSin the two probes that assert no harness build arg returns to the Dockerfile (default-provisioning.sh,sandbox-boot-guard-ci.sh). Dropping it there would let that arg come back unnoticed.Operator impact
oh config set install.deepagents truenow exits 1 withunknown field, because theOH_CONFIG_FIELDSrow is gone β verified against a rebuilt CLI, not assumed:One honest limitation: a stale
install.deepagentsalready present in anoh.jsonis ignored, not rejected. That is the pre-existing behaviour for any unrecognisedinstall.*key βinstall.nonsensebehaves identically β so it is not something this change introduces, and fixing general unknown-field validation is a separate concern I did not fold in here.Verification
EVAL=0(103 probes, no regressions).npx vitest run948 passed / 8 failed β the knowncompose-args.test.tsbaseline; the count drops from 952 because four deepagents-specific tests went with the entry. shellcheck 0,tsc --noEmit0,npm run build0.oh.jsonre-validated as JSON after the key removal.I also corrected #909's CHANGELOG entry to stop naming DeepAgents. The changelog describes the release, not the PR sequence, and both land together β a reader should not see "changed how DeepAgents installs" followed by "removed DeepAgents" in one version.
Stack depth
This is #903 β #905 β #907 β #909 β #910. I put it here rather than on
developmentbecause the alternative was resolving ~15 conflicting files twice; the overlap with #909 is almost total. That is a reason, not a justification β the stack is two levels past what the/gitskill calls rare, and every further rebase costs more. Landing #903 would collapse most of it.