From 61b1801158ac0b8c076f57ca4bdcc61f602b3527 Mon Sep 17 00:00:00 2001 From: ryaneggz Date: Sun, 30 Aug 2026 19:13:00 -0600 Subject: [PATCH 1/5] 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. --- .devcontainer/Dockerfile | 17 +- .devcontainer/entrypoint.sh | 7 + .oh/cli/src/__tests__/harness-catalog.test.ts | 32 +++ .oh/cli/src/lib/harnesses/catalog.ts | 22 +- .oh/evals/RESULTS.md | 205 +++++++++--------- .oh/evals/probes/harness-home-provisioning.sh | 74 +++++++ .oh/scripts/provision-harnesses.sh | 137 ++++++++++++ CHANGELOG.md | 1 + 8 files changed, 382 insertions(+), 113 deletions(-) create mode 100755 .oh/evals/probes/harness-home-provisioning.sh create mode 100755 .oh/scripts/provision-harnesses.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 60fe7347..50c254a4 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -86,6 +86,7 @@ SHELL ["/bin/bash", "-c"] ARG AGENTS="claude-code,codex,pi-coding-agent" ARG INSTALL_OPENCODE=false ARG INSTALL_GROK_BUILD=false +ARG BAKE_HARNESSES=true RUN set -e; \ declare -A PKG=( \ @@ -94,13 +95,15 @@ RUN set -e; \ [pi-coding-agent]=@earendil-works/pi-coding-agent \ [opencode]=opencode-ai \ ); \ - IFS=',' read -ra agents <<< "$AGENTS"; \ - for a in "${agents[@]}"; do \ - if [ "$a" = "pi-coding-agent" ]; then continue; fi; \ - pkg="${PKG[$a]:-}"; \ - if [ -n "$pkg" ]; then npm install -g "$pkg"; \ - else echo "Unknown agent: $a"; exit 1; fi; \ - done; \ + if [ "${BAKE_HARNESSES}" = "true" ]; then \ + IFS=',' read -ra agents <<< "$AGENTS"; \ + for a in "${agents[@]}"; do \ + if [ "$a" = "pi-coding-agent" ]; then continue; fi; \ + pkg="${PKG[$a]:-}"; \ + if [ -n "$pkg" ]; then npm install -g "$pkg"; \ + else echo "Unknown agent: $a"; exit 1; fi; \ + done; \ + else echo "Skipping baked agent CLI installs (BAKE_HARNESSES=${BAKE_HARNESSES}); .oh/scripts/provision-harnesses.sh installs them into /home/sandbox/.local at boot"; fi; \ if [ "${INSTALL_OPENCODE}" = "true" ]; then npm install -g opencode-ai; \ else echo "Skipping OpenCode CLI install (INSTALL_OPENCODE=false)"; fi; \ if [ "${INSTALL_GROK_BUILD}" = "true" ]; then \ diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index 7365c944..848fa4d0 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -150,6 +150,13 @@ if [ -x "$HARNESS/.oh/scripts/link-providers.sh" ]; then fi fi +if [ "${OH_PROVISION_HARNESSES:-true}" = "true" ] \ + && [ -x "$HARNESS/.oh/scripts/provision-harnesses.sh" ]; then + if ! bash "$HARNESS/.oh/scripts/provision-harnesses.sh"; then + echo "[entrypoint] WARNING: harness provisioning did not complete; run: bash .oh/scripts/provision-harnesses.sh" >&2 + fi +fi + if [ "${OH_PROVISION_PYTHON:-true}" = "true" ] \ && [ -x "$HARNESS/.oh/scripts/provision-python.sh" ]; then if ! bash "$HARNESS/.oh/scripts/provision-python.sh"; then diff --git a/.oh/cli/src/__tests__/harness-catalog.test.ts b/.oh/cli/src/__tests__/harness-catalog.test.ts index f2f342c1..f202ad96 100644 --- a/.oh/cli/src/__tests__/harness-catalog.test.ts +++ b/.oh/cli/src/__tests__/harness-catalog.test.ts @@ -16,6 +16,8 @@ const read = (rel: string): string => readFileSync(join(REPO_ROOT, rel), "utf8") const DOCKERFILE = read(".devcontainer/Dockerfile"); const COMPOSE_YML = read(".devcontainer/docker-compose.yml"); const CONFIG_DOC = read("docs/configuration.md"); +const ENTRYPOINT = read(".devcontainer/entrypoint.sh"); +const NPM_USER_PREFIX = "/home/sandbox/.local"; function versionPins(argv: readonly string[]): string[] { const pins = new Set(); @@ -127,6 +129,36 @@ describe("harness catalog", () => { } }); + describe("default harnesses install into the home mount, not the image", () => { + const defaults = HARNESS_CATALOG.filter((h) => h.kind === "default"); + + it("covers claude-code, codex and pi", () => { + expect(defaults.map((h) => h.id).sort()).toEqual(["claude-code", "codex", "pi"]); + }); + + it("declares NPM_USER_PREFIX as the prefix the catalog installs into", () => { + expect(DOCKERFILE).toContain(`ENV NPM_USER_PREFIX="${NPM_USER_PREFIX}"`); + }); + + it.each(defaults.map((h) => [h.id, h] as const))( + "%s: installs as the sandbox user into NPM_USER_PREFIX", + (_id, h) => { + expect(h.installUser).toBe("sandbox"); + expect(h.installArgv).toContain(NPM_USER_PREFIX); + }, + ); + + it("keeps claude-code's postinstall, which copies the native binary over the placeholder", () => { + expect(findHarness("claude-code")!.installArgv).not.toContain("--ignore-scripts"); + }); + + it("lets the image bake be turned off, and provisions the same harnesses at boot", () => { + expect(DOCKERFILE).toMatch(/^ARG BAKE_HARNESSES=true$/m); + expect(ENTRYPOINT).toContain("OH_PROVISION_HARNESSES"); + expect(ENTRYPOINT).toContain(".oh/scripts/provision-harnesses.sh"); + }); + }); + it("findHarness resolves known ids and rejects unknown ones", () => { expect(findHarness("opencode")?.harnessKey).toBe("opencode"); expect(findHarness("grok-build")?.harnessKey).toBe("grok_build"); diff --git a/.oh/cli/src/lib/harnesses/catalog.ts b/.oh/cli/src/lib/harnesses/catalog.ts index 11be7a7c..3e876399 100644 --- a/.oh/cli/src/lib/harnesses/catalog.ts +++ b/.oh/cli/src/lib/harnesses/catalog.ts @@ -22,8 +22,15 @@ export const HARNESS_CATALOG: readonly HarnessEntry[] = [ id: "claude-code", title: "Claude Code", binary: "claude", - installArgv: ["npm", "install", "-g", "@anthropic-ai/claude-code"], - installUser: "root", + installArgv: [ + "npm", + "--prefix", + "/home/sandbox/.local", + "install", + "-g", + "@anthropic-ai/claude-code", + ], + installUser: "sandbox", verifyArgv: ["claude", "--version"], docsPath: "docs/harnesses/claude-code.md", kind: "default", @@ -32,8 +39,15 @@ export const HARNESS_CATALOG: readonly HarnessEntry[] = [ id: "codex", title: "Codex", binary: "codex", - installArgv: ["npm", "install", "-g", "@openai/codex"], - installUser: "root", + installArgv: [ + "npm", + "--prefix", + "/home/sandbox/.local", + "install", + "-g", + "@openai/codex", + ], + installUser: "sandbox", verifyArgv: ["codex", "--version"], docsPath: "docs/harnesses/codex.md", kind: "default", diff --git a/.oh/evals/RESULTS.md b/.oh/evals/RESULTS.md index ee53d0d4..59532872 100644 --- a/.oh/evals/RESULTS.md +++ b/.oh/evals/RESULTS.md @@ -6,107 +6,108 @@ probe id; git history is the time series.** Schema and exit-code semantics are i | probe | tier | last-run (UTC) | status | source | |-------|------|----------------|--------|--------| -| advisor-monitored-loop | A | 2026-08-31 00:49 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | -| agent-browser-cli | A | 2026-08-31 00:49 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | -| agents-identity-contract | A | 2026-08-31 00:49 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | -| artifact-contract-audit | A | 2026-08-31 00:49 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | -| audit-dispatcher-contract | A | 2026-08-31 00:49 | PASS | issue #645 — audit consolidation public taxonomy | -| audit-implementation-behavior | A | 2026-08-31 00:49 | PASS | issue #645 — implementation root/repo/browser behavior | -| audit-pr-acquire | A | 2026-08-31 00:49 | PASS | issue #645 — production PR acquisition behavior | -| audit-pr-classifier | A | 2026-08-31 00:49 | PASS | issue #645 — deterministic focused and queue PR classifier | -| audit-run-root-contract | A | 2026-08-31 00:49 | PASS | issue #645 — executable immutable audit root/run correlation | -| audit-shellcheck-coverage | A | 2026-08-31 00:49 | PASS | issue #645 — private audit scripts require release and CI lint coverage | -| audit-stale-references | A | 2026-08-31 00:49 | PASS | issue #645 — clean-breaking audit migration | -| boot-lint-glob | A | 2026-08-31 00:49 | PASS | issue #90, issue #120 | -| builder-skill-consolidation | A | 2026-08-31 00:49 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | -| capability-benchmark-schema | A | 2026-08-31 00:49 | PASS | issue #167 — capability benchmark instrument | -| cc-safety-net-wiring | A | 2026-08-31 00:49 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | -| changelog-entry-length | A | 2026-08-31 00:49 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | -| cleanup-tasks-scoped-guard | A | 2026-08-31 00:49 | PASS | issue #85 | -| cleanup-tasks-worktree-grooming | A | 2026-08-31 00:49 | PASS | issue #168; issue #327 | -| cli-publish-typecheck-scope | A | 2026-08-31 00:49 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | -| close-issues-on-development | A | 2026-08-31 00:49 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | -| codex-stale-response-retry | A | 2026-08-31 00:49 | PASS | issue #506 — Codex previous_response_not_found RCA | -| compose-config-path-parity | A | 2026-08-31 00:49 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | -| config-schema-parity | A | 2026-08-31 00:49 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | -| context-tier-size-budget | A | 2026-08-31 00:49 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | -| cron-claude-codex-fallback | A | 2026-08-31 00:49 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | -| cron-watchdog | A | 2026-08-31 00:49 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | -| crons-directory-guide | A | 2026-08-31 00:49 | PASS | issue #874 | -| curl-bash-safe-alternatives | A | 2026-08-31 00:49 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | -| datasets-schema | A | 2026-08-31 00:49 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | -| debugmcp-availability | A | 2026-08-31 00:49 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | -| delegate-model-effort-policy | A | 2026-08-31 00:49 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | -| devtcp-hook | A | 2026-08-31 00:49 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | -| docker-inspect-env-guard | A | 2026-08-31 00:49 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | -| docs-build-fast-path | A | 2026-08-31 00:49 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | -| drift-check-cron-staleness-glob | A | 2026-08-31 00:49 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | -| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 00:49 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | -| eval-ci-gate | A | 2026-08-31 00:49 | PASS | #103 — eval probe suite gated in CI | -| eval-gate | A | 2026-08-31 00:49 | PASS | retro lesson 2026-06-11 (eval-gate) | -| eval-results-atomic | A | 2026-08-31 00:49 | PASS | issue #83 (eval-results-atomic-write) | -| eval-runner-exit | A | 2026-08-31 00:49 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | -| eval-runs-once-per-cycle | A | 2026-08-31 00:49 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | -| execution-target-contract | A | 2026-08-31 00:49 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | -| get-oh-bootstrap | A | 2026-08-31 00:49 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | -| git-skill | A | 2026-08-31 00:49 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | -| harness-audit-empty-output-gate | A | 2026-08-31 00:49 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | -| harness-ci-core-paths | A | 2026-08-31 00:49 | PASS | #165 — core sandbox config files must trigger harness CI | -| harness-ci-hooks-paths | A | 2026-08-31 00:49 | PASS | issue #202 — credential/security hook changes must trigger harness CI | -| harness-yaml-migration | A | 2026-08-31 00:49 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | -| health-check-docker-stats | A | 2026-08-31 00:49 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | -| health-check-socket-degrade | A | 2026-08-31 00:49 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | -| heartbeat-logging-contract | A | 2026-08-31 00:49 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | -| image-seed-hygiene | A | 2026-08-31 00:49 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | -| markitdown-wiki-ingest | A | 2026-08-31 00:49 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | -| next-dev-prod | A | 2026-08-31 00:49 | SKIPPED | retro lesson 2026-06-04 | -| oh-compose-env-wiring | A | 2026-08-31 00:49 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | -| oh-config-surfaces | A | 2026-08-31 00:49 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | -| oh-destroy-guard | A | 2026-08-31 00:49 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | -| oh-devcontainer-restructure | A | 2026-08-31 00:49 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | -| oh-home-mount | A | 2026-08-31 00:49 | PASS | issue #898 (single $HOME mount) 2026-08-30 | -| oh-image-only-deploy | A | 2026-08-31 00:49 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | -| oh-init-headless-config | A | 2026-08-31 00:49 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | -| oh-init-scaffold | A | 2026-08-31 00:49 | PASS | issue #531 Phase 2 | -| oh-lifecycle-surface | A | 2026-08-31 00:49 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | -| oh-npm-package | A | 2026-08-31 00:49 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | -| oh-payload-manifest | A | 2026-08-31 00:49 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | -| oh-sandbox-image-mode | A | 2026-08-31 00:49 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | -| oh-shipped-repo-overridable | A | 2026-08-31 00:49 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | -| oh-standalone-lifecycle | A | 2026-08-31 00:49 | PASS | issue #564 | -| oh-update | A | 2026-08-31 00:49 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | -| operator-config-guard | A | 2026-08-31 00:49 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | -| pnpm-audit-ci-gate | A | 2026-08-31 00:49 | PASS | issue #171 — pnpm security audits must run in CI | -| post-bridge-publish-confirmation | A | 2026-08-31 00:49 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | -| prd-output-path-contract | A | 2026-08-31 00:49 | PASS | retro lesson 2026-06-19 | -| prompt-miner-schema-compat | A | 2026-08-31 00:49 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | -| prompt-miner-symlink-entrypoint | A | 2026-08-31 00:49 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | -| prompt-miner-weakness-record | A | 2026-08-31 00:49 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | -| protected-path-deletion | A | 2026-08-31 00:49 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | -| protected-paths-resolve | A | 2026-08-31 00:49 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | -| registry-portability-gate | A | 2026-08-31 00:49 | PASS | issue #758 | -| registry-portability | A | 2026-08-31 00:49 | SKIPPED | issue #758 | -| retro-deterministic-contract | A | 2026-08-31 00:49 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | -| rl-delegation-write-worker | A | 2026-08-31 00:49 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | -| rlm-context-budget | A | 2026-08-31 00:49 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | -| runtime-preflight-gate | A | 2026-08-31 00:49 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | -| sandbox-boot-guard-ci | A | 2026-08-31 00:49 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | -| sandbox-node-base | A | 2026-08-31 00:49 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | -| skill-paths | A | 2026-08-31 00:49 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | -| skills-dir-clean | A | 2026-08-31 00:49 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | -| skills-task-tool-coupling | A | 2026-08-31 00:49 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | -| skills-vendored | A | 2026-08-31 00:49 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | -| slack-admin-command-surface | A | 2026-08-31 00:49 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | -| spec-family-contract | A | 2026-08-31 00:49 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | -| spec-ready-finalization | A | 2026-08-31 00:49 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | -| ste-checker-contract | A | 2026-08-31 00:49 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | -| submitted-by-trailers | A | 2026-08-31 00:49 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | -| sync-skill-contract | A | 2026-08-31 00:49 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | -| tool-catalog-boundary | A | 2026-08-31 00:49 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | -| version-parity | A | 2026-08-31 00:49 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | -| weigh-scorer-contract | A | 2026-08-31 00:49 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | -| wiki-readme-index | A | 2026-08-31 00:49 | PASS | issue #132 — wiki README index drift guard | -| workflow-boundaries | A | 2026-08-31 00:49 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | -| worktrees-layout | A | 2026-08-31 00:49 | PASS | issue #872 | +| advisor-monitored-loop | A | 2026-08-31 01:10 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | +| agent-browser-cli | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | +| agents-identity-contract | A | 2026-08-31 01:10 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | +| artifact-contract-audit | A | 2026-08-31 01:10 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | +| audit-dispatcher-contract | A | 2026-08-31 01:10 | PASS | issue #645 — audit consolidation public taxonomy | +| audit-implementation-behavior | A | 2026-08-31 01:10 | PASS | issue #645 — implementation root/repo/browser behavior | +| audit-pr-acquire | A | 2026-08-31 01:10 | PASS | issue #645 — production PR acquisition behavior | +| audit-pr-classifier | A | 2026-08-31 01:10 | PASS | issue #645 — deterministic focused and queue PR classifier | +| audit-run-root-contract | A | 2026-08-31 01:10 | PASS | issue #645 — executable immutable audit root/run correlation | +| audit-shellcheck-coverage | A | 2026-08-31 01:10 | PASS | issue #645 — private audit scripts require release and CI lint coverage | +| audit-stale-references | A | 2026-08-31 01:10 | PASS | issue #645 — clean-breaking audit migration | +| boot-lint-glob | A | 2026-08-31 01:10 | PASS | issue #90, issue #120 | +| builder-skill-consolidation | A | 2026-08-31 01:10 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | +| capability-benchmark-schema | A | 2026-08-31 01:10 | PASS | issue #167 — capability benchmark instrument | +| cc-safety-net-wiring | A | 2026-08-31 01:10 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | +| changelog-entry-length | A | 2026-08-31 01:10 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | +| cleanup-tasks-scoped-guard | A | 2026-08-31 01:10 | PASS | issue #85 | +| cleanup-tasks-worktree-grooming | A | 2026-08-31 01:10 | PASS | issue #168; issue #327 | +| cli-publish-typecheck-scope | A | 2026-08-31 01:10 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | +| close-issues-on-development | A | 2026-08-31 01:10 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | +| codex-stale-response-retry | A | 2026-08-31 01:10 | PASS | issue #506 — Codex previous_response_not_found RCA | +| compose-config-path-parity | A | 2026-08-31 01:10 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | +| config-schema-parity | A | 2026-08-31 01:10 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | +| context-tier-size-budget | A | 2026-08-31 01:10 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | +| cron-claude-codex-fallback | A | 2026-08-31 01:10 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | +| cron-watchdog | A | 2026-08-31 01:10 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | +| crons-directory-guide | A | 2026-08-31 01:10 | PASS | issue #874 | +| curl-bash-safe-alternatives | A | 2026-08-31 01:10 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | +| datasets-schema | A | 2026-08-31 01:10 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | +| debugmcp-availability | A | 2026-08-31 01:10 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | +| delegate-model-effort-policy | A | 2026-08-31 01:10 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | +| devtcp-hook | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | +| docker-inspect-env-guard | A | 2026-08-31 01:10 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | +| docs-build-fast-path | A | 2026-08-31 01:10 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | +| drift-check-cron-staleness-glob | A | 2026-08-31 01:10 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | +| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 01:10 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | +| eval-ci-gate | A | 2026-08-31 01:10 | PASS | #103 — eval probe suite gated in CI | +| eval-gate | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-11 (eval-gate) | +| eval-results-atomic | A | 2026-08-31 01:10 | PASS | issue #83 (eval-results-atomic-write) | +| eval-runner-exit | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | +| eval-runs-once-per-cycle | A | 2026-08-31 01:10 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | +| execution-target-contract | A | 2026-08-31 01:10 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | +| get-oh-bootstrap | A | 2026-08-31 01:10 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | +| git-skill | A | 2026-08-31 01:10 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | +| harness-audit-empty-output-gate | A | 2026-08-31 01:10 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | +| harness-ci-core-paths | A | 2026-08-31 01:10 | PASS | #165 — core sandbox config files must trigger harness CI | +| harness-ci-hooks-paths | A | 2026-08-31 01:10 | PASS | issue #202 — credential/security hook changes must trigger harness CI | +| harness-home-provisioning | A | 2026-08-31 01:10 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | +| harness-yaml-migration | A | 2026-08-31 01:10 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | +| health-check-docker-stats | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | +| health-check-socket-degrade | A | 2026-08-31 01:10 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | +| heartbeat-logging-contract | A | 2026-08-31 01:10 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | +| image-seed-hygiene | A | 2026-08-31 01:10 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | +| markitdown-wiki-ingest | A | 2026-08-31 01:10 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | +| next-dev-prod | A | 2026-08-31 01:10 | SKIPPED | retro lesson 2026-06-04 | +| oh-compose-env-wiring | A | 2026-08-31 01:10 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | +| oh-config-surfaces | A | 2026-08-31 01:10 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | +| oh-destroy-guard | A | 2026-08-31 01:10 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | +| oh-devcontainer-restructure | A | 2026-08-31 01:10 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | +| oh-home-mount | A | 2026-08-31 01:10 | PASS | issue #898 (single $HOME mount) 2026-08-30 | +| oh-image-only-deploy | A | 2026-08-31 01:10 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | +| oh-init-headless-config | A | 2026-08-31 01:10 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | +| oh-init-scaffold | A | 2026-08-31 01:10 | PASS | issue #531 Phase 2 | +| oh-lifecycle-surface | A | 2026-08-31 01:10 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | +| oh-npm-package | A | 2026-08-31 01:10 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | +| oh-payload-manifest | A | 2026-08-31 01:10 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | +| oh-sandbox-image-mode | A | 2026-08-31 01:10 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | +| oh-shipped-repo-overridable | A | 2026-08-31 01:10 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | +| oh-standalone-lifecycle | A | 2026-08-31 01:10 | PASS | issue #564 | +| oh-update | A | 2026-08-31 01:10 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | +| operator-config-guard | A | 2026-08-31 01:10 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | +| pnpm-audit-ci-gate | A | 2026-08-31 01:10 | PASS | issue #171 — pnpm security audits must run in CI | +| post-bridge-publish-confirmation | A | 2026-08-31 01:10 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | +| prd-output-path-contract | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-19 | +| prompt-miner-schema-compat | A | 2026-08-31 01:10 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | +| prompt-miner-symlink-entrypoint | A | 2026-08-31 01:10 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | +| prompt-miner-weakness-record | A | 2026-08-31 01:10 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | +| protected-path-deletion | A | 2026-08-31 01:10 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | +| protected-paths-resolve | A | 2026-08-31 01:10 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | +| registry-portability-gate | A | 2026-08-31 01:10 | PASS | issue #758 | +| registry-portability | A | 2026-08-31 01:10 | SKIPPED | issue #758 | +| retro-deterministic-contract | A | 2026-08-31 01:10 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | +| rl-delegation-write-worker | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | +| rlm-context-budget | A | 2026-08-31 01:10 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | +| runtime-preflight-gate | A | 2026-08-31 01:10 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | +| sandbox-boot-guard-ci | A | 2026-08-31 01:10 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | +| sandbox-node-base | A | 2026-08-31 01:10 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | +| skill-paths | A | 2026-08-31 01:10 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | +| skills-dir-clean | A | 2026-08-31 01:10 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | +| skills-task-tool-coupling | A | 2026-08-31 01:10 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | +| skills-vendored | A | 2026-08-31 01:10 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | +| slack-admin-command-surface | A | 2026-08-31 01:10 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | +| spec-family-contract | A | 2026-08-31 01:10 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | +| spec-ready-finalization | A | 2026-08-31 01:10 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | +| ste-checker-contract | A | 2026-08-31 01:10 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | +| submitted-by-trailers | A | 2026-08-31 01:10 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | +| sync-skill-contract | A | 2026-08-31 01:10 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | +| tool-catalog-boundary | A | 2026-08-31 01:10 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | +| version-parity | A | 2026-08-31 01:10 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | +| weigh-scorer-contract | A | 2026-08-31 01:10 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | +| wiki-readme-index | A | 2026-08-31 01:10 | PASS | issue #132 — wiki README index drift guard | +| workflow-boundaries | A | 2026-08-31 01:10 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | +| worktrees-layout | A | 2026-08-31 01:10 | PASS | issue #872 | diff --git a/.oh/evals/probes/harness-home-provisioning.sh b/.oh/evals/probes/harness-home-provisioning.sh new file mode 100755 index 00000000..8ae329db --- /dev/null +++ b/.oh/evals/probes/harness-home-provisioning.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# tier: A +# source: #902 — `oh harness install` must work from inside the sandbox, where +# sudo has no NOPASSWD, so default harnesses install into the home mount +# desc: every kind:"default" harness installs as the sandbox user into +# NPM_USER_PREFIX, claude-code keeps its postinstall, and the boot path +# carries the OH_PROVISION_HARNESSES guard and its provisioner. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +CATALOG="$ROOT/.oh/cli/src/lib/harnesses/catalog.ts" +ENTRY="$ROOT/.devcontainer/entrypoint.sh" +DOCKERFILE="$ROOT/.devcontainer/Dockerfile" +PROVISIONER="$ROOT/.oh/scripts/provision-harnesses.sh" + +for f in "$CATALOG" "$ENTRY" "$DOCKERFILE"; do + if [[ ! -f $f ]]; then + echo "SKIPPED: absent: $f" >&2 + exit 2 + fi +done + +PREFIX=$(sed -n 's/^ENV NPM_USER_PREFIX="\([^"]*\)".*/\1/p' "$DOCKERFILE" | head -1) +if [[ -z $PREFIX ]]; then + echo "SKIPPED: Dockerfile declares no ENV NPM_USER_PREFIX to anchor the install prefix" >&2 + exit 2 +fi + +missing=() + +entries=$(awk ' + /^ \{$/ { buf=""; inb=1; next } + /^ \},$/ { if (inb) print buf; inb=0; next } + inb { buf = buf $0 " " } +' "$CATALOG") + +defaults=0 +while IFS= read -r entry; do + [[ $entry == *'kind: "default"'* ]] || continue + defaults=$((defaults + 1)) + id=$(sed -n 's/.*id: "\([^"]*\)".*/\1/p' <<<"$entry") + if [[ $entry == *'installUser: "root"'* ]]; then + missing+=("harnesses/catalog.ts: default harness \"$id\" installs as root — inside the sandbox that becomes \`sudo -n\`, and /etc/sudoers.d/sandbox has no NOPASSWD") + fi + if [[ $entry != *"$PREFIX"* ]]; then + missing+=("harnesses/catalog.ts: default harness \"$id\" does not install into $PREFIX — a baked install under /usr/lib/node_modules cannot be upgraded by a running sandbox") + fi + if [[ $id == "claude-code" && $entry == *"--ignore-scripts"* ]]; then + missing+=("harnesses/catalog.ts: claude-code uses --ignore-scripts — its postinstall copies the native binary over the placeholder, so \`claude --version\` fails with 'claude native binary not installed'") + fi +done <<<"$entries" + +if ((defaults == 0)); then + echo "SKIPPED: no kind:\"default\" harness parsed out of $CATALOG" >&2 + exit 2 +fi + +grep -qF 'OH_PROVISION_HARNESSES' "$ENTRY" \ + || missing+=("entrypoint.sh: no OH_PROVISION_HARNESSES guard — nothing provisions harnesses into the home mount at boot") +grep -qF 'provision-harnesses.sh' "$ENTRY" \ + || missing+=("entrypoint.sh: does not call .oh/scripts/provision-harnesses.sh") +grep -qF 'WARNING: harness provisioning did not complete' "$ENTRY" \ + || missing+=("entrypoint.sh: harness provisioning does not warn-and-continue — an offline sandbox must still come up as a usable shell") +[[ -x $PROVISIONER ]] \ + || missing+=(".oh/scripts/provision-harnesses.sh: missing or not executable") +grep -qE '^ARG BAKE_HARNESSES=' "$DOCKERFILE" \ + || missing+=("Dockerfile: no ARG BAKE_HARNESSES — the image bake cannot be turned off once provisioning owns the install") + +if ((${#missing[@]})); then + printf 'REGRESSION: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +echo "PASS: all $defaults default harnesses install as the sandbox user into $PREFIX, and the boot path provisions them" >&2 diff --git a/.oh/scripts/provision-harnesses.sh b/.oh/scripts/provision-harnesses.sh new file mode 100755 index 00000000..4dbc5003 --- /dev/null +++ b/.oh/scripts/provision-harnesses.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SANDBOX_USER="${OH_SANDBOX_USER:-sandbox}" +OH_BIN="${OH_BIN:-oh}" + +MODE="provision" +case "${1:-}" in + --verify) MODE="verify" ;; + "") ;; + *) echo "usage: $(basename "$0") [--verify]" >&2; exit 2 ;; +esac + +log() { echo "[provision-harnesses] $*"; } + +die() { + echo "[provision-harnesses] ERROR: $1" >&2 + shift + for line in "$@"; do echo "[provision-harnesses] $line" >&2; done + exit 1 +} + +if [ "$(id -u)" = "0" ]; then + if ! id "$SANDBOX_USER" >/dev/null 2>&1; then + die "user '$SANDBOX_USER' does not exist" \ + "set OH_SANDBOX_USER to the in-container agent user." + fi + USER_HOME=$(getent passwd "$SANDBOX_USER" | cut -d: -f6) + [ -n "$USER_HOME" ] || die "cannot resolve home directory for '$SANDBOX_USER'" + + install -d -o "$SANDBOX_USER" -g "$SANDBOX_USER" \ + "$USER_HOME/.local" \ + "$USER_HOME/.local/bin" \ + "$USER_HOME/.local/lib" \ + "$USER_HOME/.npm" 2>/dev/null || true + + if command -v gosu >/dev/null 2>&1; then + exec gosu "$SANDBOX_USER" env HOME="$USER_HOME" "$0" "$@" + fi + exec su "$SANDBOX_USER" -s /bin/bash -c "HOME='$USER_HOME' '$0' $*" +fi + +HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}" +export HOME + +NPM_USER_PREFIX="${NPM_USER_PREFIX:-$HOME/.local}" +export NPM_USER_PREFIX +export PATH="$NPM_USER_PREFIX/bin:$PATH" + +check_writable() { + local dir="$1" + if [ ! -d "$dir" ]; then + mkdir -p "$dir" 2>/dev/null && return 0 + local parent; parent=$(dirname "$dir") + die "cannot create $dir (parent $parent is owned by $(stat -c '%U:%G' "$parent" 2>/dev/null || echo unknown))" \ + "this is an ownership bug in provisioning, not something to fix with 'sudo npm' —" \ + "a root-owned harness under $NPM_USER_PREFIX is unusable by the '$SANDBOX_USER' user." \ + "repair from the host or as root:" \ + " docker exec -u root chown -R $SANDBOX_USER:$SANDBOX_USER $parent" + fi + if [ ! -w "$dir" ]; then + die "$dir is not writable by $(id -un) (owned by $(stat -c '%U:%G' "$dir" 2>/dev/null || echo unknown))" \ + "do not work around this with 'sudo npm' — it installs under /usr/lib/node_modules," \ + "which no running sandbox can upgrade in place." \ + "repair from the host or as root:" \ + " docker exec -u root chown -R $SANDBOX_USER:$SANDBOX_USER $dir" + fi +} + +for d in "$NPM_USER_PREFIX" "$NPM_USER_PREFIX/bin" "$NPM_USER_PREFIX/lib" "$HOME/.npm"; do + [ "$MODE" = "verify" ] && [ ! -d "$d" ] && continue + check_writable "$d" +done + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" +export OH_EXECUTION_TARGET="${OH_EXECUTION_TARGET:-local}" + +command -v "$OH_BIN" >/dev/null 2>&1 || die \ + "the oh CLI is not on PATH as '$OH_BIN'" \ + "the image installs it to /usr/local/bin/oh; rebuild the sandbox image:" \ + " oh sandbox" + +command -v jq >/dev/null 2>&1 || die \ + "jq is not on PATH" \ + "the image installs it with apt; rebuild the sandbox image:" \ + " oh sandbox" + +STATES="" +if ! STATES="$("$OH_BIN" harness list --json 2>/dev/null)" || [ -z "$STATES" ]; then + die "'$OH_BIN harness list --json' produced no catalog" \ + "the CLI at $(command -v "$OH_BIN") predates \`oh harness\`; the harness catalog" \ + "is the only source of truth for what to install, so there is nothing to provision." \ + "rebuild the sandbox image from this control plane:" \ + " oh sandbox" +fi + +DEFAULTS="$(jq -r '.[] | select(.kind == "default") | "\(.id)\t\(.installed)"' <<<"$STATES")" +[ -n "$DEFAULTS" ] || die \ + "the harness catalog declares no default harnesses" \ + "check .oh/cli/src/lib/harnesses/catalog.ts" + +missing=() +failed=() + +while IFS=$'\t' read -r id installed; do + [ -n "$id" ] || continue + if [ "$installed" = "true" ]; then + log "OK $id present (unpinned — an existing install is never replaced)" + continue + fi + if [ "$MODE" = "verify" ]; then + missing+=("$id") + continue + fi + log "installing $id into $NPM_USER_PREFIX" + if "$OH_BIN" harness install "$id" --no-persist; then + log "OK $id installed" + else + failed+=("$id") + fi +done <<<"$DEFAULTS" + +if [ "$MODE" = "verify" ] && ((${#missing[@]})); then + die "default harnesses are not installed: ${missing[*]}" \ + "run: bash .oh/scripts/provision-harnesses.sh" +fi + +if ((${#failed[@]})); then + die "failed to install: ${failed[*]}" \ + "each install runs as '$SANDBOX_USER' into $NPM_USER_PREFIX; a network outage is the usual cause." \ + "re-run once the sandbox has network:" \ + " bash .oh/scripts/provision-harnesses.sh" +fi + +log "OK default harnesses provisioned under $NPM_USER_PREFIX" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad8fc9b..5ecce67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Update policy and release automation live in [`/git`](.claude/skills/git/SKILL.m - **BREAKING:** Retire the `projectRoot` / `OH_PROJECT_ROOT` config knob — the checkout is fixed at `/home/sandbox/harness`, nested inside the home mount ([#898](https://github.com/mifunedev/openharness/issues/898)). ### Added +- Provision the default harnesses into `/home/sandbox/.local` at boot, gated by `OH_PROVISION_HARNESSES`, so `oh harness install` also works from inside the sandbox ([#902](https://github.com/mifunedev/openharness/issues/902)). - Add `oh-home-mount.sh`, a tier-A probe holding the single-`$HOME`-mount contract: one mount per compose file, the baked `/opt/home-seed`, and the checkout prune that replaces `-xdev` ([#898](https://github.com/mifunedev/openharness/issues/898)). - Add `skills-task-tool-coupling.sh`, a tier-A probe holding the canonical skill pack and the sandbox in agreement about the Claude-Code-only task tools ([#886](https://github.com/mifunedev/openharness/issues/886)). From d91cda31b2769af27781ca975539c05179f76b6c Mon Sep 17 00:00:00 2001 From: ryaneggz Date: Sun, 30 Aug 2026 19:43:32 -0600 Subject: [PATCH 2/5] 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. --- .devcontainer/Dockerfile | 11 +- .devcontainer/entrypoint.sh | 2 +- .oh/cli/src/__tests__/harness-catalog.test.ts | 45 ++++ .oh/cli/src/__tests__/harness.test.ts | 54 ++++- .oh/cli/src/cli.ts | 22 +- .oh/cli/src/commands/harness.ts | 18 +- .oh/cli/src/lib/harnesses/catalog.ts | 4 + .oh/evals/RESULTS.md | 206 +++++++++--------- .oh/evals/probes/harness-home-provisioning.sh | 40 +++- .oh/scripts/provision-harnesses.sh | 41 +++- docs/harnesses/overview.md | 4 +- 11 files changed, 317 insertions(+), 130 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 50c254a4..beff0791 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -103,7 +103,7 @@ RUN set -e; \ if [ -n "$pkg" ]; then npm install -g "$pkg"; \ else echo "Unknown agent: $a"; exit 1; fi; \ done; \ - else echo "Skipping baked agent CLI installs (BAKE_HARNESSES=${BAKE_HARNESSES}); .oh/scripts/provision-harnesses.sh installs them into /home/sandbox/.local at boot"; fi; \ + else echo "Skipping baked Claude Code and Codex installs (BAKE_HARNESSES=${BAKE_HARNESSES}); .oh/scripts/provision-harnesses.sh installs every kind:\"default\" harness into /home/sandbox/.local at boot"; fi; \ if [ "${INSTALL_OPENCODE}" = "true" ]; then npm install -g opencode-ai; \ else echo "Skipping OpenCode CLI install (INSTALL_OPENCODE=false)"; fi; \ if [ "${INSTALL_GROK_BUILD}" = "true" ]; then \ @@ -132,6 +132,7 @@ ENV UV_CACHE_DIR=/home/sandbox/.cache/uv FROM base AS home ARG AGENTS="claude-code,codex,pi-coding-agent" +ARG BAKE_HARNESSES=true RUN install -d -o sandbox -g sandbox -m 0700 /home/sandbox/.ssh \ && install -d -o sandbox -g sandbox \ /home/sandbox/.local \ @@ -144,9 +145,11 @@ RUN install -d -o sandbox -g sandbox -m 0700 /home/sandbox/.ssh \ RUN set -e; \ install -d -o sandbox -g sandbox "$NPM_USER_PREFIX"; \ - if [[ ",${AGENTS}," == *",pi-coding-agent,"* ]]; then \ - su - sandbox -c 'npm --prefix "$HOME/.local" install -g --ignore-scripts @earendil-works/pi-coding-agent'; \ - fi; \ + if [ "${BAKE_HARNESSES}" = "true" ]; then \ + if [[ ",${AGENTS}," == *",pi-coding-agent,"* ]]; then \ + su - sandbox -c 'npm --prefix "$HOME/.local" install -g --ignore-scripts @earendil-works/pi-coding-agent'; \ + fi; \ + else echo "Skipping baked Pi install (BAKE_HARNESSES=${BAKE_HARNESSES}); .oh/scripts/provision-harnesses.sh installs it into /home/sandbox/.local at boot"; fi; \ rm -rf /home/sandbox/.npm RUN su - sandbox -c "RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c \"\$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\" --unattended" \ diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index 848fa4d0..cbe342a9 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -152,7 +152,7 @@ fi if [ "${OH_PROVISION_HARNESSES:-true}" = "true" ] \ && [ -x "$HARNESS/.oh/scripts/provision-harnesses.sh" ]; then - if ! bash "$HARNESS/.oh/scripts/provision-harnesses.sh"; then + if ! OH_EXECUTION_TARGET=local timeout "${OH_PROVISION_HARNESSES_TIMEOUT:-180}" bash "$HARNESS/.oh/scripts/provision-harnesses.sh"; then echo "[entrypoint] WARNING: harness provisioning did not complete; run: bash .oh/scripts/provision-harnesses.sh" >&2 fi fi diff --git a/.oh/cli/src/__tests__/harness-catalog.test.ts b/.oh/cli/src/__tests__/harness-catalog.test.ts index f202ad96..cc9b8bd7 100644 --- a/.oh/cli/src/__tests__/harness-catalog.test.ts +++ b/.oh/cli/src/__tests__/harness-catalog.test.ts @@ -19,6 +19,30 @@ const CONFIG_DOC = read("docs/configuration.md"); const ENTRYPOINT = read(".devcontainer/entrypoint.sh"); const NPM_USER_PREFIX = "/home/sandbox/.local"; +const BAKE_GATE = 'if [ "${BAKE_HARNESSES}" = "true" ]'; + +function dockerfileStage(stage: string): string { + const lines = DOCKERFILE.split("\n"); + const start = lines.findIndex((l) => new RegExp(`^FROM .* AS ${stage}$`).test(l)); + if (start === -1) return ""; + const rest = lines.slice(start + 1); + const end = rest.findIndex((l) => l.startsWith("FROM ")); + return (end === -1 ? rest : rest.slice(0, end)).join("\n"); +} + +function dockerfileRunWith(needle: string): string { + const blocks: string[] = []; + let buf: string | null = null; + for (const line of DOCKERFILE.split("\n")) { + if (buf === null && !line.startsWith("RUN ")) continue; + buf = buf === null ? line : `${buf}\n${line}`; + if (line.endsWith("\\")) continue; + blocks.push(buf); + buf = null; + } + return blocks.find((b) => b.includes(needle)) ?? ""; +} + function versionPins(argv: readonly string[]): string[] { const pins = new Set(); for (const part of argv) { @@ -157,6 +181,27 @@ describe("harness catalog", () => { expect(ENTRYPOINT).toContain("OH_PROVISION_HARNESSES"); expect(ENTRYPOINT).toContain(".oh/scripts/provision-harnesses.sh"); }); + + it.each(["base", "home"])( + "%s declares BAKE_HARNESSES, which ARG scopes to that stage alone", + (stage) => { + expect(dockerfileStage(stage)).toMatch(/^ARG BAKE_HARNESSES/m); + }, + ); + + it("gates every baked default install on BAKE_HARNESSES, pi included", () => { + expect(dockerfileRunWith("read -ra agents")).toContain(BAKE_GATE); + expect(dockerfileRunWith("--ignore-scripts @earendil-works/pi-coding-agent")).toContain( + BAKE_GATE, + ); + }); + + it("bounds the boot-path provisioner so an unreachable registry cannot stall the entrypoint", () => { + expect(ENTRYPOINT).toMatch( + /timeout "\$\{OH_PROVISION_HARNESSES_TIMEOUT:-\d+\}" bash "\$HARNESS\/\.oh\/scripts\/provision-harnesses\.sh"/, + ); + expect(ENTRYPOINT).toContain("WARNING: harness provisioning did not complete"); + }); }); it("findHarness resolves known ids and rejects unknown ones", () => { diff --git a/.oh/cli/src/__tests__/harness.test.ts b/.oh/cli/src/__tests__/harness.test.ts index 277951dc..06b9840f 100644 --- a/.oh/cli/src/__tests__/harness.test.ts +++ b/.oh/cli/src/__tests__/harness.test.ts @@ -9,6 +9,7 @@ vi.mock("node:os", async (importOriginal) => { import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { + PROBE_TIMEOUT_MS, runHarnessInstall, runHarnessList, runHarnessStatus, @@ -49,14 +50,19 @@ function makeRepo(): string { interface RecordedCall { cmd: string; args: string[]; + timeoutMs?: number; } function makeRunner( reply: (cmd: string, args: string[]) => RunResult | undefined = () => undefined, ): { calls: RecordedCall[]; run: LifecycleRunner } { const calls: RecordedCall[] = []; - const run: LifecycleRunner = (cmd, args) => { - calls.push({ cmd, args: [...args] }); + const run: LifecycleRunner = (cmd, args, opts) => { + calls.push({ + cmd, + args: [...args], + ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), + }); return reply(cmd, args) ?? { status: 0, stdout: "", stderr: "" }; }; return { calls, run }; @@ -414,6 +420,50 @@ describe("runHarnessList", () => { }); }); +describe("runHarnessList — a hung verify probe cannot stall the boot path", () => { + const INSIDE_SANDBOX: NodeJS.ProcessEnv = { OH_EXECUTION_TARGET: "local" }; + + it("bounds every probe spawn with a timeout", async () => { + const root = makeRepo(); + const { calls, run } = makeRunner(); + await runHarnessList({ cwd: root, run, env: INSIDE_SANDBOX, json: true }, makeIo().io); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) expect(call.timeoutMs).toBe(PROBE_TIMEOUT_MS); + }); + + it("reports a timed-out probe as unknown rather than throwing", async () => { + const root = makeRepo(); + const { run } = makeRunner((cmd) => + cmd === "npx" + ? { status: null, error: { code: "ETIMEDOUT", message: "spawnSync npx ETIMEDOUT" } } + : undefined, + ); + const { out, io } = makeIo(); + expect(await runHarnessList({ cwd: root, run, env: INSIDE_SANDBOX, json: true }, io)).toBe(0); + const parsed = JSON.parse(text(out)); + expect(parsed.find((h: { id: string }) => h.id === "t3code").installed).toBeNull(); + expect(parsed.find((h: { id: string }) => h.id === "claude-code").installed).toBe(true); + }); + + it("--defaults probes only the default harnesses, never the registry-touching ones", async () => { + const root = makeRepo(); + const { calls, run } = makeRunner(); + const { out, io } = makeIo(); + expect( + await runHarnessList( + { cwd: root, run, env: INSIDE_SANDBOX, json: true, defaultsOnly: true }, + io, + ), + ).toBe(0); + expect(JSON.parse(text(out)).map((h: { id: string }) => h.id)).toEqual([ + "claude-code", + "codex", + "pi", + ]); + expect(calls.map((c) => c.cmd).sort()).toEqual(["claude", "codex", "pi"]); + }); +}); + describe("runHarnessStatus", () => { it("with no name behaves like list", async () => { const root = makeRepo(); diff --git a/.oh/cli/src/cli.ts b/.oh/cli/src/cli.ts index 0c4efe9a..733ab131 100644 --- a/.oh/cli/src/cli.ts +++ b/.oh/cli/src/cli.ts @@ -268,7 +268,7 @@ export function printHarnessHelp(): void { process.stdout.write(`oh harness — Install and inspect agent CLI harnesses Usage: - oh harness list List known harnesses and their state + oh harness list [--defaults] List known harnesses and their state oh harness install Install a harness into the sandbox oh harness status [name] Show installed/enabled state @@ -281,6 +281,7 @@ and exits 0. Flags: --persist-only Only set the oh.json install.* field (no container work) --no-persist Live-install only; leave oh.json unchanged + --defaults List only kind:"default" harnesses (list) --json Machine-readable output (list/status) Harnesses: @@ -820,11 +821,18 @@ export interface HarnessArgs { name?: string; persistOnly: boolean; noPersist: boolean; + defaultsOnly: boolean; json: boolean; } export function parseHarnessArgs(rest: string[]): ParseResult { - const args: HarnessArgs = { help: false, persistOnly: false, noPersist: false, json: false }; + const args: HarnessArgs = { + help: false, + persistOnly: false, + noPersist: false, + defaultsOnly: false, + json: false, + }; if (rest.length === 0 || isHelpFlag(rest[0])) { return { ok: true, args: { ...args, help: true } }; } @@ -835,6 +843,8 @@ export function parseHarnessArgs(rest: string[]): ParseResult { args.persistOnly = true; } else if (token === "--no-persist") { args.noPersist = true; + } else if (token === "--defaults") { + args.defaultsOnly = true; } else if (token === "--json") { args.json = true; } else if (token.startsWith("-")) { @@ -861,6 +871,12 @@ export function parseHarnessArgs(rest: string[]): ParseResult { if (sub === "list" && name !== undefined) { return { ok: false, error: `oh harness list: unexpected argument "${name}"` }; } + if (args.defaultsOnly && sub !== "list") { + return { + ok: false, + error: `oh harness ${sub}: --defaults applies to \`oh harness list\` only`, + }; + } if (args.persistOnly && args.noPersist) { return { ok: false, @@ -1335,7 +1351,7 @@ async function main(argv: string[]): Promise { stderr: (s) => process.stderr.write(s), }; if (a.subcommand === "list") { - return await runHarnessList({ json: a.json }, io); + return await runHarnessList({ json: a.json, defaultsOnly: a.defaultsOnly }, io); } if (a.subcommand === "status") { return await runHarnessStatus(a.name, { json: a.json }, io); diff --git a/.oh/cli/src/commands/harness.ts b/.oh/cli/src/commands/harness.ts index d7c89d51..5050e405 100644 --- a/.oh/cli/src/commands/harness.ts +++ b/.oh/cli/src/commands/harness.ts @@ -12,6 +12,7 @@ import { setInstallFlag, } from "../lib/env-file.js"; import { + defaultHarnesses, findHarness, harnessIds, HARNESS_CATALOG, @@ -30,6 +31,7 @@ export interface HarnessOptions { run?: LifecycleRunner; json?: boolean; env?: NodeJS.ProcessEnv; + defaultsOnly?: boolean; } export interface HarnessInstallOptions extends HarnessOptions { @@ -46,6 +48,8 @@ interface HarnessState { docs: string; } +export const PROBE_TIMEOUT_MS = 15_000; + function isReachable(status: string): boolean { return status === "ready" || status === "starting"; } @@ -73,6 +77,7 @@ async function probeInstalled( argv: [...entry.verifyArgv], user: "sandbox", stdio: "capture", + timeoutMs: PROBE_TIMEOUT_MS, }); return r.exitCode === 0; } catch (err) { @@ -85,9 +90,9 @@ async function collectStates( root: string, run: LifecycleRunner, env?: NodeJS.ProcessEnv, - only?: HarnessEntry, + only?: readonly HarnessEntry[], ): Promise { - const entries = only ? [only] : [...HARNESS_CATALOG]; + const entries = only ? [...only] : [...HARNESS_CATALOG]; const target = targetFor(root, run, env); let reachable = false; @@ -140,7 +145,12 @@ function renderTable(states: HarnessState[], io: HarnessIO): void { export async function runHarnessList(opts: HarnessOptions, io: HarnessIO): Promise { const run = opts.run ?? spawnRunner; const root = resolveProjectRoot(opts.cwd); - const states = await collectStates(root, run, opts.env); + const states = await collectStates( + root, + run, + opts.env, + opts.defaultsOnly === true ? defaultHarnesses() : undefined, + ); if (opts.json) { io.stdout(`${JSON.stringify(states, null, 2)}\n`); } else { @@ -169,7 +179,7 @@ export async function runHarnessStatus( if (!only) return unknownHarness(name, io); } - const states = await collectStates(root, run, opts.env, only); + const states = await collectStates(root, run, opts.env, only ? [only] : undefined); if (opts.json) { io.stdout(`${JSON.stringify(only ? states[0] : states, null, 2)}\n`); } else { diff --git a/.oh/cli/src/lib/harnesses/catalog.ts b/.oh/cli/src/lib/harnesses/catalog.ts index 3e876399..b6c0f408 100644 --- a/.oh/cli/src/lib/harnesses/catalog.ts +++ b/.oh/cli/src/lib/harnesses/catalog.ts @@ -152,6 +152,10 @@ export const HARNESS_CATALOG: readonly HarnessEntry[] = [ }, ]; +export function defaultHarnesses(): readonly HarnessEntry[] { + return HARNESS_CATALOG.filter((h) => h.kind === "default"); +} + export function findHarness(id: string): HarnessEntry | undefined { return HARNESS_CATALOG.find((h) => h.id === id); } diff --git a/.oh/evals/RESULTS.md b/.oh/evals/RESULTS.md index 59532872..a6c7ac50 100644 --- a/.oh/evals/RESULTS.md +++ b/.oh/evals/RESULTS.md @@ -6,108 +6,108 @@ probe id; git history is the time series.** Schema and exit-code semantics are i | probe | tier | last-run (UTC) | status | source | |-------|------|----------------|--------|--------| -| advisor-monitored-loop | A | 2026-08-31 01:10 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | -| agent-browser-cli | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | -| agents-identity-contract | A | 2026-08-31 01:10 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | -| artifact-contract-audit | A | 2026-08-31 01:10 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | -| audit-dispatcher-contract | A | 2026-08-31 01:10 | PASS | issue #645 — audit consolidation public taxonomy | -| audit-implementation-behavior | A | 2026-08-31 01:10 | PASS | issue #645 — implementation root/repo/browser behavior | -| audit-pr-acquire | A | 2026-08-31 01:10 | PASS | issue #645 — production PR acquisition behavior | -| audit-pr-classifier | A | 2026-08-31 01:10 | PASS | issue #645 — deterministic focused and queue PR classifier | -| audit-run-root-contract | A | 2026-08-31 01:10 | PASS | issue #645 — executable immutable audit root/run correlation | -| audit-shellcheck-coverage | A | 2026-08-31 01:10 | PASS | issue #645 — private audit scripts require release and CI lint coverage | -| audit-stale-references | A | 2026-08-31 01:10 | PASS | issue #645 — clean-breaking audit migration | -| boot-lint-glob | A | 2026-08-31 01:10 | PASS | issue #90, issue #120 | -| builder-skill-consolidation | A | 2026-08-31 01:10 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | -| capability-benchmark-schema | A | 2026-08-31 01:10 | PASS | issue #167 — capability benchmark instrument | -| cc-safety-net-wiring | A | 2026-08-31 01:10 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | -| changelog-entry-length | A | 2026-08-31 01:10 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | -| cleanup-tasks-scoped-guard | A | 2026-08-31 01:10 | PASS | issue #85 | -| cleanup-tasks-worktree-grooming | A | 2026-08-31 01:10 | PASS | issue #168; issue #327 | -| cli-publish-typecheck-scope | A | 2026-08-31 01:10 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | -| close-issues-on-development | A | 2026-08-31 01:10 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | -| codex-stale-response-retry | A | 2026-08-31 01:10 | PASS | issue #506 — Codex previous_response_not_found RCA | -| compose-config-path-parity | A | 2026-08-31 01:10 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | -| config-schema-parity | A | 2026-08-31 01:10 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | -| context-tier-size-budget | A | 2026-08-31 01:10 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | -| cron-claude-codex-fallback | A | 2026-08-31 01:10 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | -| cron-watchdog | A | 2026-08-31 01:10 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | -| crons-directory-guide | A | 2026-08-31 01:10 | PASS | issue #874 | -| curl-bash-safe-alternatives | A | 2026-08-31 01:10 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | -| datasets-schema | A | 2026-08-31 01:10 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | -| debugmcp-availability | A | 2026-08-31 01:10 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | -| delegate-model-effort-policy | A | 2026-08-31 01:10 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | -| devtcp-hook | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | -| docker-inspect-env-guard | A | 2026-08-31 01:10 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | -| docs-build-fast-path | A | 2026-08-31 01:10 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | -| drift-check-cron-staleness-glob | A | 2026-08-31 01:10 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | -| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 01:10 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | -| eval-ci-gate | A | 2026-08-31 01:10 | PASS | #103 — eval probe suite gated in CI | -| eval-gate | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-11 (eval-gate) | -| eval-results-atomic | A | 2026-08-31 01:10 | PASS | issue #83 (eval-results-atomic-write) | -| eval-runner-exit | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | -| eval-runs-once-per-cycle | A | 2026-08-31 01:10 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | -| execution-target-contract | A | 2026-08-31 01:10 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | -| get-oh-bootstrap | A | 2026-08-31 01:10 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | -| git-skill | A | 2026-08-31 01:10 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | -| harness-audit-empty-output-gate | A | 2026-08-31 01:10 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | -| harness-ci-core-paths | A | 2026-08-31 01:10 | PASS | #165 — core sandbox config files must trigger harness CI | -| harness-ci-hooks-paths | A | 2026-08-31 01:10 | PASS | issue #202 — credential/security hook changes must trigger harness CI | -| harness-home-provisioning | A | 2026-08-31 01:10 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | -| harness-yaml-migration | A | 2026-08-31 01:10 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | -| health-check-docker-stats | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | -| health-check-socket-degrade | A | 2026-08-31 01:10 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | -| heartbeat-logging-contract | A | 2026-08-31 01:10 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | -| image-seed-hygiene | A | 2026-08-31 01:10 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | -| markitdown-wiki-ingest | A | 2026-08-31 01:10 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | -| next-dev-prod | A | 2026-08-31 01:10 | SKIPPED | retro lesson 2026-06-04 | -| oh-compose-env-wiring | A | 2026-08-31 01:10 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | -| oh-config-surfaces | A | 2026-08-31 01:10 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | -| oh-destroy-guard | A | 2026-08-31 01:10 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | -| oh-devcontainer-restructure | A | 2026-08-31 01:10 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | -| oh-home-mount | A | 2026-08-31 01:10 | PASS | issue #898 (single $HOME mount) 2026-08-30 | -| oh-image-only-deploy | A | 2026-08-31 01:10 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | -| oh-init-headless-config | A | 2026-08-31 01:10 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | -| oh-init-scaffold | A | 2026-08-31 01:10 | PASS | issue #531 Phase 2 | -| oh-lifecycle-surface | A | 2026-08-31 01:10 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | -| oh-npm-package | A | 2026-08-31 01:10 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | -| oh-payload-manifest | A | 2026-08-31 01:10 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | -| oh-sandbox-image-mode | A | 2026-08-31 01:10 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | -| oh-shipped-repo-overridable | A | 2026-08-31 01:10 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | -| oh-standalone-lifecycle | A | 2026-08-31 01:10 | PASS | issue #564 | -| oh-update | A | 2026-08-31 01:10 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | -| operator-config-guard | A | 2026-08-31 01:10 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | -| pnpm-audit-ci-gate | A | 2026-08-31 01:10 | PASS | issue #171 — pnpm security audits must run in CI | -| post-bridge-publish-confirmation | A | 2026-08-31 01:10 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | -| prd-output-path-contract | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-19 | -| prompt-miner-schema-compat | A | 2026-08-31 01:10 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | -| prompt-miner-symlink-entrypoint | A | 2026-08-31 01:10 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | -| prompt-miner-weakness-record | A | 2026-08-31 01:10 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | -| protected-path-deletion | A | 2026-08-31 01:10 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | -| protected-paths-resolve | A | 2026-08-31 01:10 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | -| registry-portability-gate | A | 2026-08-31 01:10 | PASS | issue #758 | -| registry-portability | A | 2026-08-31 01:10 | SKIPPED | issue #758 | -| retro-deterministic-contract | A | 2026-08-31 01:10 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | -| rl-delegation-write-worker | A | 2026-08-31 01:10 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | -| rlm-context-budget | A | 2026-08-31 01:10 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | -| runtime-preflight-gate | A | 2026-08-31 01:10 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | -| sandbox-boot-guard-ci | A | 2026-08-31 01:10 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | -| sandbox-node-base | A | 2026-08-31 01:10 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | -| skill-paths | A | 2026-08-31 01:10 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | -| skills-dir-clean | A | 2026-08-31 01:10 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | -| skills-task-tool-coupling | A | 2026-08-31 01:10 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | -| skills-vendored | A | 2026-08-31 01:10 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | -| slack-admin-command-surface | A | 2026-08-31 01:10 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | -| spec-family-contract | A | 2026-08-31 01:10 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | -| spec-ready-finalization | A | 2026-08-31 01:10 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | -| ste-checker-contract | A | 2026-08-31 01:10 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | -| submitted-by-trailers | A | 2026-08-31 01:10 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | -| sync-skill-contract | A | 2026-08-31 01:10 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | -| tool-catalog-boundary | A | 2026-08-31 01:10 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | -| version-parity | A | 2026-08-31 01:10 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | -| weigh-scorer-contract | A | 2026-08-31 01:10 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | -| wiki-readme-index | A | 2026-08-31 01:10 | PASS | issue #132 — wiki README index drift guard | -| workflow-boundaries | A | 2026-08-31 01:10 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | -| worktrees-layout | A | 2026-08-31 01:10 | PASS | issue #872 | +| advisor-monitored-loop | A | 2026-08-31 01:42 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | +| agent-browser-cli | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | +| agents-identity-contract | A | 2026-08-31 01:42 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | +| artifact-contract-audit | A | 2026-08-31 01:42 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | +| audit-dispatcher-contract | A | 2026-08-31 01:42 | PASS | issue #645 — audit consolidation public taxonomy | +| audit-implementation-behavior | A | 2026-08-31 01:42 | PASS | issue #645 — implementation root/repo/browser behavior | +| audit-pr-acquire | A | 2026-08-31 01:42 | PASS | issue #645 — production PR acquisition behavior | +| audit-pr-classifier | A | 2026-08-31 01:42 | PASS | issue #645 — deterministic focused and queue PR classifier | +| audit-run-root-contract | A | 2026-08-31 01:42 | PASS | issue #645 — executable immutable audit root/run correlation | +| audit-shellcheck-coverage | A | 2026-08-31 01:42 | PASS | issue #645 — private audit scripts require release and CI lint coverage | +| audit-stale-references | A | 2026-08-31 01:42 | PASS | issue #645 — clean-breaking audit migration | +| boot-lint-glob | A | 2026-08-31 01:42 | PASS | issue #90, issue #120 | +| builder-skill-consolidation | A | 2026-08-31 01:42 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | +| capability-benchmark-schema | A | 2026-08-31 01:42 | PASS | issue #167 — capability benchmark instrument | +| cc-safety-net-wiring | A | 2026-08-31 01:42 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | +| changelog-entry-length | A | 2026-08-31 01:42 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | +| cleanup-tasks-scoped-guard | A | 2026-08-31 01:42 | PASS | issue #85 | +| cleanup-tasks-worktree-grooming | A | 2026-08-31 01:42 | PASS | issue #168; issue #327 | +| cli-publish-typecheck-scope | A | 2026-08-31 01:42 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | +| close-issues-on-development | A | 2026-08-31 01:42 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | +| codex-stale-response-retry | A | 2026-08-31 01:42 | PASS | issue #506 — Codex previous_response_not_found RCA | +| compose-config-path-parity | A | 2026-08-31 01:42 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | +| config-schema-parity | A | 2026-08-31 01:42 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | +| context-tier-size-budget | A | 2026-08-31 01:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | +| cron-claude-codex-fallback | A | 2026-08-31 01:42 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | +| cron-watchdog | A | 2026-08-31 01:42 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | +| crons-directory-guide | A | 2026-08-31 01:42 | PASS | issue #874 | +| curl-bash-safe-alternatives | A | 2026-08-31 01:42 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | +| datasets-schema | A | 2026-08-31 01:42 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | +| debugmcp-availability | A | 2026-08-31 01:42 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | +| delegate-model-effort-policy | A | 2026-08-31 01:42 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | +| devtcp-hook | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | +| docker-inspect-env-guard | A | 2026-08-31 01:42 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | +| docs-build-fast-path | A | 2026-08-31 01:42 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | +| drift-check-cron-staleness-glob | A | 2026-08-31 01:42 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | +| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 01:42 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | +| eval-ci-gate | A | 2026-08-31 01:42 | PASS | #103 — eval probe suite gated in CI | +| eval-gate | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-11 (eval-gate) | +| eval-results-atomic | A | 2026-08-31 01:42 | PASS | issue #83 (eval-results-atomic-write) | +| eval-runner-exit | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | +| eval-runs-once-per-cycle | A | 2026-08-31 01:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | +| execution-target-contract | A | 2026-08-31 01:42 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | +| get-oh-bootstrap | A | 2026-08-31 01:42 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | +| git-skill | A | 2026-08-31 01:42 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | +| harness-audit-empty-output-gate | A | 2026-08-31 01:42 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | +| harness-ci-core-paths | A | 2026-08-31 01:42 | PASS | #165 — core sandbox config files must trigger harness CI | +| harness-ci-hooks-paths | A | 2026-08-31 01:42 | PASS | issue #202 — credential/security hook changes must trigger harness CI | +| harness-home-provisioning | A | 2026-08-31 01:42 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | +| harness-yaml-migration | A | 2026-08-31 01:42 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | +| health-check-docker-stats | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | +| health-check-socket-degrade | A | 2026-08-31 01:42 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | +| heartbeat-logging-contract | A | 2026-08-31 01:42 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | +| image-seed-hygiene | A | 2026-08-31 01:42 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | +| markitdown-wiki-ingest | A | 2026-08-31 01:42 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | +| next-dev-prod | A | 2026-08-31 01:42 | SKIPPED | retro lesson 2026-06-04 | +| oh-compose-env-wiring | A | 2026-08-31 01:42 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | +| oh-config-surfaces | A | 2026-08-31 01:42 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | +| oh-destroy-guard | A | 2026-08-31 01:42 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | +| oh-devcontainer-restructure | A | 2026-08-31 01:42 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | +| oh-home-mount | A | 2026-08-31 01:42 | PASS | issue #898 (single $HOME mount) 2026-08-30 | +| oh-image-only-deploy | A | 2026-08-31 01:42 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | +| oh-init-headless-config | A | 2026-08-31 01:42 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | +| oh-init-scaffold | A | 2026-08-31 01:42 | PASS | issue #531 Phase 2 | +| oh-lifecycle-surface | A | 2026-08-31 01:42 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | +| oh-npm-package | A | 2026-08-31 01:42 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | +| oh-payload-manifest | A | 2026-08-31 01:42 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | +| oh-sandbox-image-mode | A | 2026-08-31 01:42 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | +| oh-shipped-repo-overridable | A | 2026-08-31 01:42 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | +| oh-standalone-lifecycle | A | 2026-08-31 01:42 | PASS | issue #564 | +| oh-update | A | 2026-08-31 01:42 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | +| operator-config-guard | A | 2026-08-31 01:42 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | +| pnpm-audit-ci-gate | A | 2026-08-31 01:42 | PASS | issue #171 — pnpm security audits must run in CI | +| post-bridge-publish-confirmation | A | 2026-08-31 01:42 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | +| prd-output-path-contract | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-19 | +| prompt-miner-schema-compat | A | 2026-08-31 01:42 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | +| prompt-miner-symlink-entrypoint | A | 2026-08-31 01:42 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | +| prompt-miner-weakness-record | A | 2026-08-31 01:42 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | +| protected-path-deletion | A | 2026-08-31 01:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | +| protected-paths-resolve | A | 2026-08-31 01:42 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | +| registry-portability-gate | A | 2026-08-31 01:42 | PASS | issue #758 | +| registry-portability | A | 2026-08-31 01:42 | SKIPPED | issue #758 | +| retro-deterministic-contract | A | 2026-08-31 01:42 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | +| rl-delegation-write-worker | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | +| rlm-context-budget | A | 2026-08-31 01:42 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | +| runtime-preflight-gate | A | 2026-08-31 01:42 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | +| sandbox-boot-guard-ci | A | 2026-08-31 01:42 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | +| sandbox-node-base | A | 2026-08-31 01:42 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | +| skill-paths | A | 2026-08-31 01:42 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | +| skills-dir-clean | A | 2026-08-31 01:42 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | +| skills-task-tool-coupling | A | 2026-08-31 01:42 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | +| skills-vendored | A | 2026-08-31 01:42 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | +| slack-admin-command-surface | A | 2026-08-31 01:42 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | +| spec-family-contract | A | 2026-08-31 01:42 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | +| spec-ready-finalization | A | 2026-08-31 01:42 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | +| ste-checker-contract | A | 2026-08-31 01:42 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | +| submitted-by-trailers | A | 2026-08-31 01:42 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | +| sync-skill-contract | A | 2026-08-31 01:42 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | +| tool-catalog-boundary | A | 2026-08-31 01:42 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | +| version-parity | A | 2026-08-31 01:42 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | +| weigh-scorer-contract | A | 2026-08-31 01:42 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | +| wiki-readme-index | A | 2026-08-31 01:42 | PASS | issue #132 — wiki README index drift guard | +| workflow-boundaries | A | 2026-08-31 01:42 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | +| worktrees-layout | A | 2026-08-31 01:42 | PASS | issue #872 | diff --git a/.oh/evals/probes/harness-home-provisioning.sh b/.oh/evals/probes/harness-home-provisioning.sh index 8ae329db..17f70163 100755 --- a/.oh/evals/probes/harness-home-provisioning.sh +++ b/.oh/evals/probes/harness-home-provisioning.sh @@ -63,8 +63,44 @@ grep -qF 'WARNING: harness provisioning did not complete' "$ENTRY" \ || missing+=("entrypoint.sh: harness provisioning does not warn-and-continue — an offline sandbox must still come up as a usable shell") [[ -x $PROVISIONER ]] \ || missing+=(".oh/scripts/provision-harnesses.sh: missing or not executable") -grep -qE '^ARG BAKE_HARNESSES=' "$DOCKERFILE" \ - || missing+=("Dockerfile: no ARG BAKE_HARNESSES — the image bake cannot be turned off once provisioning owns the install") +run_block_with() { + awk -v needle="$1" ' + function flush() { + if (index(buf, needle)) print buf + buf = "" + } + /^RUN / { buf = $0; cont = ($0 ~ /\\$/); if (!cont) flush(); next } + cont { buf = buf "\n" $0; cont = ($0 ~ /\\$/); if (!cont) flush() } + ' "$DOCKERFILE" +} + +BAKE_GATE='if [ "${BAKE_HARNESSES}" = "true" ]' + +stage_body() { + awk -v stage="$1" ' + /^FROM / { inb = ($0 ~ ("AS " stage "$")); next } + inb + ' "$DOCKERFILE" +} + +for stage in base home; do + stage_body "$stage" | grep -qE '^ARG BAKE_HARNESSES' \ + || missing+=("Dockerfile: the $stage stage does not declare ARG BAKE_HARNESSES — ARG is stage-scoped, so the build arg is empty there and every \${BAKE_HARNESSES} test in that stage reads as unset") +done + +AGENTS_BLOCK=$(run_block_with 'read -ra agents') +if [[ -z $AGENTS_BLOCK ]]; then + missing+=("Dockerfile: found no RUN that loops over \$AGENTS — the probe cannot tell whether BAKE_HARNESSES gates the bake") +elif [[ $AGENTS_BLOCK != *"$BAKE_GATE"* ]]; then + missing+=("Dockerfile: the RUN that installs \$AGENTS does not test \${BAKE_HARNESSES} — ARG BAKE_HARNESSES is declared but dead, so BAKE_HARNESSES=false still bakes the agent CLIs") +fi + +PI_BLOCK=$(run_block_with '--ignore-scripts @earendil-works/pi-coding-agent') +if [[ -z $PI_BLOCK ]]; then + missing+=("Dockerfile: found no RUN that bakes pi into \$NPM_USER_PREFIX — the probe cannot tell whether BAKE_HARNESSES gates it") +elif [[ $PI_BLOCK != *"$BAKE_GATE"* ]]; then + missing+=("Dockerfile: the RUN that bakes pi does not test \${BAKE_HARNESSES} — ARG is stage-scoped, so BAKE_HARNESSES=false unbakes claude and codex but leaves pi in the image") +fi if ((${#missing[@]})); then printf 'REGRESSION: %s\n' "${missing[@]}" >&2 diff --git a/.oh/scripts/provision-harnesses.sh b/.oh/scripts/provision-harnesses.sh index 4dbc5003..b1f9f2cf 100755 --- a/.oh/scripts/provision-harnesses.sh +++ b/.oh/scripts/provision-harnesses.sh @@ -2,7 +2,7 @@ set -euo pipefail -SANDBOX_USER="${OH_SANDBOX_USER:-sandbox}" +SANDBOX_USER="sandbox" OH_BIN="${OH_BIN:-oh}" MODE="provision" @@ -21,10 +21,27 @@ die() { exit 1 } +inside_sandbox() { + case "${OH_EXECUTION_TARGET:-}" in + local) return 0 ;; + docker-compose) return 1 ;; + esac + [ -f /.dockerenv ] && [ -n "${SANDBOX_NAME:-}" ] +} + +inside_sandbox || die \ + "this provisions /home/$SANDBOX_USER/.local inside the sandbox and must not run on the host" \ + "open a sandbox shell first:" \ + " oh shell" \ + " bash .oh/scripts/provision-harnesses.sh" + +export OH_EXECUTION_TARGET=local + if [ "$(id -u)" = "0" ]; then if ! id "$SANDBOX_USER" >/dev/null 2>&1; then die "user '$SANDBOX_USER' does not exist" \ - "set OH_SANDBOX_USER to the in-container agent user." + "this script provisions the sandbox image's agent user; rebuild the image:" \ + " oh sandbox" fi USER_HOME=$(getent passwd "$SANDBOX_USER" | cut -d: -f6) [ -n "$USER_HOME" ] || die "cannot resolve home directory for '$SANDBOX_USER'" @@ -38,7 +55,7 @@ if [ "$(id -u)" = "0" ]; then if command -v gosu >/dev/null 2>&1; then exec gosu "$SANDBOX_USER" env HOME="$USER_HOME" "$0" "$@" fi - exec su "$SANDBOX_USER" -s /bin/bash -c "HOME='$USER_HOME' '$0' $*" + exec su "$SANDBOX_USER" -s /bin/bash -c "HOME='$USER_HOME' OH_EXECUTION_TARGET=local '$0' $*" fi HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}" @@ -75,7 +92,6 @@ done ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT" -export OH_EXECUTION_TARGET="${OH_EXECUTION_TARGET:-local}" command -v "$OH_BIN" >/dev/null 2>&1 || die \ "the oh CLI is not on PATH as '$OH_BIN'" \ @@ -88,8 +104,8 @@ command -v jq >/dev/null 2>&1 || die \ " oh sandbox" STATES="" -if ! STATES="$("$OH_BIN" harness list --json 2>/dev/null)" || [ -z "$STATES" ]; then - die "'$OH_BIN harness list --json' produced no catalog" \ +if ! STATES="$("$OH_BIN" harness list --defaults --json 2>/dev/null)" || [ -z "$STATES" ]; then + die "'$OH_BIN harness list --defaults --json' produced no catalog" \ "the CLI at $(command -v "$OH_BIN") predates \`oh harness\`; the harness catalog" \ "is the only source of truth for what to install, so there is nothing to provision." \ "rebuild the sandbox image from this control plane:" \ @@ -115,7 +131,7 @@ while IFS=$'\t' read -r id installed; do continue fi log "installing $id into $NPM_USER_PREFIX" - if "$OH_BIN" harness install "$id" --no-persist; then + if "$OH_BIN" harness install "$id" --no-persist Date: Sun, 30 Aug 2026 20:07:02 -0600 Subject: [PATCH 3/5] task: stop baking the default harnesses into the sandbox image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .devcontainer/Dockerfile | 29 +-- .devcontainer/docker-compose.image-only.yml | 7 +- .devcontainer/docker-compose.yml | 7 +- .github/workflows/sandbox-boot-guard.yml | 20 +- .oh/cli/src/__tests__/harness-catalog.test.ts | 50 ++--- .oh/cli/src/commands/harness.ts | 2 + .oh/evals/RESULTS.md | 206 +++++++++--------- .oh/evals/probes/harness-home-provisioning.sh | 72 +++--- .oh/evals/probes/sandbox-boot-guard-ci.sh | 29 ++- .../__tests__/sandbox-boot-smoke.test.ts | 55 +++++ .../__tests__/sandbox-healthcheck.test.ts | 8 +- .../__tests__/verify-sandbox-image.test.ts | 53 +++++ .oh/scripts/sandbox-boot-smoke.sh | 78 ++++++- .oh/scripts/verify-sandbox-image.sh | 29 ++- CHANGELOG.md | 3 + docs/deployment-prebuilt-image.md | 9 +- docs/harnesses/opencode.md | 2 +- docs/harnesses/overview.md | 2 +- docs/installation.md | 24 +- docs/quickstart.md | 11 +- 20 files changed, 478 insertions(+), 218 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index beff0791..ffc38e4b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -83,27 +83,10 @@ RUN corepack enable && corepack prepare pnpm@10.33.0 --activate \ SHELL ["/bin/bash", "-c"] -ARG AGENTS="claude-code,codex,pi-coding-agent" ARG INSTALL_OPENCODE=false ARG INSTALL_GROK_BUILD=false -ARG BAKE_HARNESSES=true RUN set -e; \ - declare -A PKG=( \ - [claude-code]=@anthropic-ai/claude-code \ - [codex]=@openai/codex \ - [pi-coding-agent]=@earendil-works/pi-coding-agent \ - [opencode]=opencode-ai \ - ); \ - if [ "${BAKE_HARNESSES}" = "true" ]; then \ - IFS=',' read -ra agents <<< "$AGENTS"; \ - for a in "${agents[@]}"; do \ - if [ "$a" = "pi-coding-agent" ]; then continue; fi; \ - pkg="${PKG[$a]:-}"; \ - if [ -n "$pkg" ]; then npm install -g "$pkg"; \ - else echo "Unknown agent: $a"; exit 1; fi; \ - done; \ - else echo "Skipping baked Claude Code and Codex installs (BAKE_HARNESSES=${BAKE_HARNESSES}); .oh/scripts/provision-harnesses.sh installs every kind:\"default\" harness into /home/sandbox/.local at boot"; fi; \ if [ "${INSTALL_OPENCODE}" = "true" ]; then npm install -g opencode-ai; \ else echo "Skipping OpenCode CLI install (INSTALL_OPENCODE=false)"; fi; \ if [ "${INSTALL_GROK_BUILD}" = "true" ]; then \ @@ -131,8 +114,6 @@ ENV UV_PYTHON_INSTALL_DIR=/home/sandbox/.local/share/uv/python ENV UV_CACHE_DIR=/home/sandbox/.cache/uv FROM base AS home -ARG AGENTS="claude-code,codex,pi-coding-agent" -ARG BAKE_HARNESSES=true RUN install -d -o sandbox -g sandbox -m 0700 /home/sandbox/.ssh \ && install -d -o sandbox -g sandbox \ /home/sandbox/.local \ @@ -143,14 +124,8 @@ RUN install -d -o sandbox -g sandbox -m 0700 /home/sandbox/.ssh \ /home/sandbox/.herdr \ "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" "$UV_PYTHON_INSTALL_DIR" "$UV_CACHE_DIR" -RUN set -e; \ - install -d -o sandbox -g sandbox "$NPM_USER_PREFIX"; \ - if [ "${BAKE_HARNESSES}" = "true" ]; then \ - if [[ ",${AGENTS}," == *",pi-coding-agent,"* ]]; then \ - su - sandbox -c 'npm --prefix "$HOME/.local" install -g --ignore-scripts @earendil-works/pi-coding-agent'; \ - fi; \ - else echo "Skipping baked Pi install (BAKE_HARNESSES=${BAKE_HARNESSES}); .oh/scripts/provision-harnesses.sh installs it into /home/sandbox/.local at boot"; fi; \ - rm -rf /home/sandbox/.npm +RUN install -d -o sandbox -g sandbox "$NPM_USER_PREFIX" \ + && rm -rf /home/sandbox/.npm RUN su - sandbox -c "RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c \"\$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\" --unattended" \ && su - sandbox -c "git clone --depth 1 https://github.com/zsh-users/zsh-autosuggestions /home/sandbox/.oh-my-zsh/custom/plugins/zsh-autosuggestions" \ diff --git a/.devcontainer/docker-compose.image-only.yml b/.devcontainer/docker-compose.image-only.yml index 95062956..1ef9f6b1 100644 --- a/.devcontainer/docker-compose.image-only.yml +++ b/.devcontainer/docker-compose.image-only.yml @@ -57,7 +57,12 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 300s + # Boot now installs the default harnesses into the home mount instead of + # unpacking them from the image (entrypoint.sh, bounded by + # OH_PROVISION_HARNESSES_TIMEOUT). A cold first boot on a fresh home mount + # therefore spends up to 180s on npm before the control plane is reachable. + # 300s left ~120s for the rest of boot; 600s keeps the same headroom. + start_period: 600s restart: unless-stopped volumes: diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index aaa05520..3cd0fba0 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -83,7 +83,12 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 300s + # Boot now installs the default harnesses into the home mount instead of + # unpacking them from the image (entrypoint.sh, bounded by + # OH_PROVISION_HARNESSES_TIMEOUT). A cold first boot on a fresh home mount + # therefore spends up to 180s on npm before the control plane is reachable. + # 300s left ~120s for the rest of boot; 600s keeps the same headroom. + start_period: 600s restart: unless-stopped volumes: diff --git a/.github/workflows/sandbox-boot-guard.yml b/.github/workflows/sandbox-boot-guard.yml index 448a863a..f4b4c9f8 100644 --- a/.github/workflows/sandbox-boot-guard.yml +++ b/.github/workflows/sandbox-boot-guard.yml @@ -92,7 +92,7 @@ jobs: # The devcontainer bind-mounts the checkout (..:/home/sandbox/harness) and # can run a synchronous `pnpm install` during boot when the root dependency # tree is missing or its manifest marker is stale — a cold install can - # overrun the healthcheck's ~390s unhealthy deadline and fail the boot with + # overrun the healthcheck's ~690s unhealthy deadline and fail the boot with # "missing required tmux session: cron-system". Seeding node_modules here # moves that slow/variable install OUT of the healthcheck-timed window; the # sandbox smoke step below also sets SKIP_PNPM_INSTALL=1 so the pre-seeded @@ -132,14 +132,24 @@ jobs: sh -c 'ls -la /mnt/node_modules/croner/package.json' echo "OK: pre-seeded node_modules is visible to the daemon that boots the sandbox" - - name: Boot sandbox and verify healthcheck + # This step is the ONLY place the boot-time harness install runs end to end. + # It boots on a fresh home volume (the smoke tears down with `down -v`), so + # provision-harnesses.sh does real npm work every run and the smoke asserts + # the result. Do not set OH_PROVISION_HARNESSES=false here to speed it up — + # that would return the install path to being untested dead code. + - name: Boot sandbox and verify healthcheck (exercises boot-time harness provisioning) env: SANDBOX_NAME: openharness-boot-guard-${{ github.run_id }} - BOOT_SMOKE_TIMEOUT_SECONDS: "900" + # Boot now installs the default harnesses into a fresh home volume + # (#904) before the control plane comes up, so the smoke deadline has + # to clear the compose healthcheck's 600s start_period plus the + # 3x30s retry window (~690s), not just the old 300s one. + BOOT_SMOKE_TIMEOUT_SECONDS: "1200" BOOT_SMOKE_INTERVAL_SECONDS: "10" # Deps are pre-seeded above and proven visible; SKIP_PNPM_INSTALL=1 # keeps the in-container install out of the healthcheck-timed window. - # This is the Option C contract: install slowness can no longer trip - # the ~390s unhealthy deadline. Boot is now just the cron-system spawn. + # This is the Option C contract: pnpm slowness can no longer trip the + # ~690s unhealthy deadline. What remains inside that window is the + # cron-system spawn and the boot-time harness install. SKIP_PNPM_INSTALL: "1" run: bash .oh/scripts/sandbox-boot-smoke.sh diff --git a/.oh/cli/src/__tests__/harness-catalog.test.ts b/.oh/cli/src/__tests__/harness-catalog.test.ts index cc9b8bd7..b6424492 100644 --- a/.oh/cli/src/__tests__/harness-catalog.test.ts +++ b/.oh/cli/src/__tests__/harness-catalog.test.ts @@ -19,29 +19,10 @@ const CONFIG_DOC = read("docs/configuration.md"); const ENTRYPOINT = read(".devcontainer/entrypoint.sh"); const NPM_USER_PREFIX = "/home/sandbox/.local"; -const BAKE_GATE = 'if [ "${BAKE_HARNESSES}" = "true" ]'; - -function dockerfileStage(stage: string): string { - const lines = DOCKERFILE.split("\n"); - const start = lines.findIndex((l) => new RegExp(`^FROM .* AS ${stage}$`).test(l)); - if (start === -1) return ""; - const rest = lines.slice(start + 1); - const end = rest.findIndex((l) => l.startsWith("FROM ")); - return (end === -1 ? rest : rest.slice(0, end)).join("\n"); -} - -function dockerfileRunWith(needle: string): string { - const blocks: string[] = []; - let buf: string | null = null; - for (const line of DOCKERFILE.split("\n")) { - if (buf === null && !line.startsWith("RUN ")) continue; - buf = buf === null ? line : `${buf}\n${line}`; - if (line.endsWith("\\")) continue; - blocks.push(buf); - buf = null; - } - return blocks.find((b) => b.includes(needle)) ?? ""; -} +// Comments may legitimately name a harness package; only instructions may not. +const DOCKERFILE_CODE = DOCKERFILE.split("\n") + .filter((l) => !/^\s*#/.test(l)) + .join("\n"); function versionPins(argv: readonly string[]): string[] { const pins = new Set(); @@ -176,24 +157,25 @@ describe("harness catalog", () => { expect(findHarness("claude-code")!.installArgv).not.toContain("--ignore-scripts"); }); - it("lets the image bake be turned off, and provisions the same harnesses at boot", () => { - expect(DOCKERFILE).toMatch(/^ARG BAKE_HARNESSES=true$/m); + it("provisions the default harnesses at boot rather than baking them", () => { expect(ENTRYPOINT).toContain("OH_PROVISION_HARNESSES"); expect(ENTRYPOINT).toContain(".oh/scripts/provision-harnesses.sh"); }); - it.each(["base", "home"])( - "%s declares BAKE_HARNESSES, which ARG scopes to that stage alone", - (stage) => { - expect(dockerfileStage(stage)).toMatch(/^ARG BAKE_HARNESSES/m); + it.each(defaults.map((h) => [h.id, h] as const))( + "%s: its npm package is absent from the Dockerfile", + (id, h) => { + const pkg = h.installArgv[h.installArgv.length - 1]; + expect(pkg, `${id} declares no install package`).toMatch(/^(@[^/]+\/)?[^-].*/); + expect( + DOCKERFILE_CODE, + `${id} is baked into the image; it belongs to provision-harnesses.sh`, + ).not.toContain(pkg); }, ); - it("gates every baked default install on BAKE_HARNESSES, pi included", () => { - expect(dockerfileRunWith("read -ra agents")).toContain(BAKE_GATE); - expect(dockerfileRunWith("--ignore-scripts @earendil-works/pi-coding-agent")).toContain( - BAKE_GATE, - ); + it("keeps no build arg that could re-bake the default harnesses", () => { + expect(DOCKERFILE_CODE).not.toMatch(/^ARG (BAKE_HARNESSES|AGENTS)=/m); }); it("bounds the boot-path provisioner so an unreachable registry cannot stall the entrypoint", () => { diff --git a/.oh/cli/src/commands/harness.ts b/.oh/cli/src/commands/harness.ts index 5050e405..cdb61c83 100644 --- a/.oh/cli/src/commands/harness.ts +++ b/.oh/cli/src/commands/harness.ts @@ -42,6 +42,7 @@ export interface HarnessInstallOptions extends HarnessOptions { interface HarnessState { id: string; title: string; + binary: string; kind: string; enabled: boolean | null; installed: boolean | null; @@ -107,6 +108,7 @@ async function collectStates( states.push({ id: entry.id, title: entry.title, + binary: entry.binary, kind: entry.kind, enabled: entry.harnessKey === undefined ? null : isInstallFlagEnabled(root, entry.harnessKey), diff --git a/.oh/evals/RESULTS.md b/.oh/evals/RESULTS.md index a6c7ac50..0d1ab988 100644 --- a/.oh/evals/RESULTS.md +++ b/.oh/evals/RESULTS.md @@ -6,108 +6,108 @@ probe id; git history is the time series.** Schema and exit-code semantics are i | probe | tier | last-run (UTC) | status | source | |-------|------|----------------|--------|--------| -| advisor-monitored-loop | A | 2026-08-31 01:42 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | -| agent-browser-cli | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | -| agents-identity-contract | A | 2026-08-31 01:42 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | -| artifact-contract-audit | A | 2026-08-31 01:42 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | -| audit-dispatcher-contract | A | 2026-08-31 01:42 | PASS | issue #645 — audit consolidation public taxonomy | -| audit-implementation-behavior | A | 2026-08-31 01:42 | PASS | issue #645 — implementation root/repo/browser behavior | -| audit-pr-acquire | A | 2026-08-31 01:42 | PASS | issue #645 — production PR acquisition behavior | -| audit-pr-classifier | A | 2026-08-31 01:42 | PASS | issue #645 — deterministic focused and queue PR classifier | -| audit-run-root-contract | A | 2026-08-31 01:42 | PASS | issue #645 — executable immutable audit root/run correlation | -| audit-shellcheck-coverage | A | 2026-08-31 01:42 | PASS | issue #645 — private audit scripts require release and CI lint coverage | -| audit-stale-references | A | 2026-08-31 01:42 | PASS | issue #645 — clean-breaking audit migration | -| boot-lint-glob | A | 2026-08-31 01:42 | PASS | issue #90, issue #120 | -| builder-skill-consolidation | A | 2026-08-31 01:42 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | -| capability-benchmark-schema | A | 2026-08-31 01:42 | PASS | issue #167 — capability benchmark instrument | -| cc-safety-net-wiring | A | 2026-08-31 01:42 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | -| changelog-entry-length | A | 2026-08-31 01:42 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | -| cleanup-tasks-scoped-guard | A | 2026-08-31 01:42 | PASS | issue #85 | -| cleanup-tasks-worktree-grooming | A | 2026-08-31 01:42 | PASS | issue #168; issue #327 | -| cli-publish-typecheck-scope | A | 2026-08-31 01:42 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | -| close-issues-on-development | A | 2026-08-31 01:42 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | -| codex-stale-response-retry | A | 2026-08-31 01:42 | PASS | issue #506 — Codex previous_response_not_found RCA | -| compose-config-path-parity | A | 2026-08-31 01:42 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | -| config-schema-parity | A | 2026-08-31 01:42 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | -| context-tier-size-budget | A | 2026-08-31 01:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | -| cron-claude-codex-fallback | A | 2026-08-31 01:42 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | -| cron-watchdog | A | 2026-08-31 01:42 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | -| crons-directory-guide | A | 2026-08-31 01:42 | PASS | issue #874 | -| curl-bash-safe-alternatives | A | 2026-08-31 01:42 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | -| datasets-schema | A | 2026-08-31 01:42 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | -| debugmcp-availability | A | 2026-08-31 01:42 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | -| delegate-model-effort-policy | A | 2026-08-31 01:42 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | -| devtcp-hook | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | -| docker-inspect-env-guard | A | 2026-08-31 01:42 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | -| docs-build-fast-path | A | 2026-08-31 01:42 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | -| drift-check-cron-staleness-glob | A | 2026-08-31 01:42 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | -| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 01:42 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | -| eval-ci-gate | A | 2026-08-31 01:42 | PASS | #103 — eval probe suite gated in CI | -| eval-gate | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-11 (eval-gate) | -| eval-results-atomic | A | 2026-08-31 01:42 | PASS | issue #83 (eval-results-atomic-write) | -| eval-runner-exit | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | -| eval-runs-once-per-cycle | A | 2026-08-31 01:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | -| execution-target-contract | A | 2026-08-31 01:42 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | -| get-oh-bootstrap | A | 2026-08-31 01:42 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | -| git-skill | A | 2026-08-31 01:42 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | -| harness-audit-empty-output-gate | A | 2026-08-31 01:42 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | -| harness-ci-core-paths | A | 2026-08-31 01:42 | PASS | #165 — core sandbox config files must trigger harness CI | -| harness-ci-hooks-paths | A | 2026-08-31 01:42 | PASS | issue #202 — credential/security hook changes must trigger harness CI | -| harness-home-provisioning | A | 2026-08-31 01:42 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | -| harness-yaml-migration | A | 2026-08-31 01:42 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | -| health-check-docker-stats | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | -| health-check-socket-degrade | A | 2026-08-31 01:42 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | -| heartbeat-logging-contract | A | 2026-08-31 01:42 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | -| image-seed-hygiene | A | 2026-08-31 01:42 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | -| markitdown-wiki-ingest | A | 2026-08-31 01:42 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | -| next-dev-prod | A | 2026-08-31 01:42 | SKIPPED | retro lesson 2026-06-04 | -| oh-compose-env-wiring | A | 2026-08-31 01:42 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | -| oh-config-surfaces | A | 2026-08-31 01:42 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | -| oh-destroy-guard | A | 2026-08-31 01:42 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | -| oh-devcontainer-restructure | A | 2026-08-31 01:42 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | -| oh-home-mount | A | 2026-08-31 01:42 | PASS | issue #898 (single $HOME mount) 2026-08-30 | -| oh-image-only-deploy | A | 2026-08-31 01:42 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | -| oh-init-headless-config | A | 2026-08-31 01:42 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | -| oh-init-scaffold | A | 2026-08-31 01:42 | PASS | issue #531 Phase 2 | -| oh-lifecycle-surface | A | 2026-08-31 01:42 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | -| oh-npm-package | A | 2026-08-31 01:42 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | -| oh-payload-manifest | A | 2026-08-31 01:42 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | -| oh-sandbox-image-mode | A | 2026-08-31 01:42 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | -| oh-shipped-repo-overridable | A | 2026-08-31 01:42 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | -| oh-standalone-lifecycle | A | 2026-08-31 01:42 | PASS | issue #564 | -| oh-update | A | 2026-08-31 01:42 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | -| operator-config-guard | A | 2026-08-31 01:42 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | -| pnpm-audit-ci-gate | A | 2026-08-31 01:42 | PASS | issue #171 — pnpm security audits must run in CI | -| post-bridge-publish-confirmation | A | 2026-08-31 01:42 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | -| prd-output-path-contract | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-19 | -| prompt-miner-schema-compat | A | 2026-08-31 01:42 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | -| prompt-miner-symlink-entrypoint | A | 2026-08-31 01:42 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | -| prompt-miner-weakness-record | A | 2026-08-31 01:42 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | -| protected-path-deletion | A | 2026-08-31 01:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | -| protected-paths-resolve | A | 2026-08-31 01:42 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | -| registry-portability-gate | A | 2026-08-31 01:42 | PASS | issue #758 | -| registry-portability | A | 2026-08-31 01:42 | SKIPPED | issue #758 | -| retro-deterministic-contract | A | 2026-08-31 01:42 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | -| rl-delegation-write-worker | A | 2026-08-31 01:42 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | -| rlm-context-budget | A | 2026-08-31 01:42 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | -| runtime-preflight-gate | A | 2026-08-31 01:42 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | -| sandbox-boot-guard-ci | A | 2026-08-31 01:42 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | -| sandbox-node-base | A | 2026-08-31 01:42 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | -| skill-paths | A | 2026-08-31 01:42 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | -| skills-dir-clean | A | 2026-08-31 01:42 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | -| skills-task-tool-coupling | A | 2026-08-31 01:42 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | -| skills-vendored | A | 2026-08-31 01:42 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | -| slack-admin-command-surface | A | 2026-08-31 01:42 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | -| spec-family-contract | A | 2026-08-31 01:42 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | -| spec-ready-finalization | A | 2026-08-31 01:42 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | -| ste-checker-contract | A | 2026-08-31 01:42 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | -| submitted-by-trailers | A | 2026-08-31 01:42 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | -| sync-skill-contract | A | 2026-08-31 01:42 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | -| tool-catalog-boundary | A | 2026-08-31 01:42 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | -| version-parity | A | 2026-08-31 01:42 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | -| weigh-scorer-contract | A | 2026-08-31 01:42 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | -| wiki-readme-index | A | 2026-08-31 01:42 | PASS | issue #132 — wiki README index drift guard | -| workflow-boundaries | A | 2026-08-31 01:42 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | -| worktrees-layout | A | 2026-08-31 01:42 | PASS | issue #872 | +| advisor-monitored-loop | A | 2026-08-31 02:05 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | +| agent-browser-cli | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | +| agents-identity-contract | A | 2026-08-31 02:05 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | +| artifact-contract-audit | A | 2026-08-31 02:05 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | +| audit-dispatcher-contract | A | 2026-08-31 02:05 | PASS | issue #645 — audit consolidation public taxonomy | +| audit-implementation-behavior | A | 2026-08-31 02:05 | PASS | issue #645 — implementation root/repo/browser behavior | +| audit-pr-acquire | A | 2026-08-31 02:05 | PASS | issue #645 — production PR acquisition behavior | +| audit-pr-classifier | A | 2026-08-31 02:05 | PASS | issue #645 — deterministic focused and queue PR classifier | +| audit-run-root-contract | A | 2026-08-31 02:05 | PASS | issue #645 — executable immutable audit root/run correlation | +| audit-shellcheck-coverage | A | 2026-08-31 02:05 | PASS | issue #645 — private audit scripts require release and CI lint coverage | +| audit-stale-references | A | 2026-08-31 02:05 | PASS | issue #645 — clean-breaking audit migration | +| boot-lint-glob | A | 2026-08-31 02:05 | PASS | issue #90, issue #120 | +| builder-skill-consolidation | A | 2026-08-31 02:05 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | +| capability-benchmark-schema | A | 2026-08-31 02:05 | PASS | issue #167 — capability benchmark instrument | +| cc-safety-net-wiring | A | 2026-08-31 02:05 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | +| changelog-entry-length | A | 2026-08-31 02:05 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | +| cleanup-tasks-scoped-guard | A | 2026-08-31 02:05 | PASS | issue #85 | +| cleanup-tasks-worktree-grooming | A | 2026-08-31 02:05 | PASS | issue #168; issue #327 | +| cli-publish-typecheck-scope | A | 2026-08-31 02:05 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | +| close-issues-on-development | A | 2026-08-31 02:05 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | +| codex-stale-response-retry | A | 2026-08-31 02:05 | PASS | issue #506 — Codex previous_response_not_found RCA | +| compose-config-path-parity | A | 2026-08-31 02:05 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | +| config-schema-parity | A | 2026-08-31 02:05 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | +| context-tier-size-budget | A | 2026-08-31 02:05 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | +| cron-claude-codex-fallback | A | 2026-08-31 02:05 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | +| cron-watchdog | A | 2026-08-31 02:05 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | +| crons-directory-guide | A | 2026-08-31 02:05 | PASS | issue #874 | +| curl-bash-safe-alternatives | A | 2026-08-31 02:05 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | +| datasets-schema | A | 2026-08-31 02:05 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | +| debugmcp-availability | A | 2026-08-31 02:05 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | +| delegate-model-effort-policy | A | 2026-08-31 02:05 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | +| devtcp-hook | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | +| docker-inspect-env-guard | A | 2026-08-31 02:05 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | +| docs-build-fast-path | A | 2026-08-31 02:05 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | +| drift-check-cron-staleness-glob | A | 2026-08-31 02:05 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | +| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 02:05 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | +| eval-ci-gate | A | 2026-08-31 02:05 | PASS | #103 — eval probe suite gated in CI | +| eval-gate | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-11 (eval-gate) | +| eval-results-atomic | A | 2026-08-31 02:05 | PASS | issue #83 (eval-results-atomic-write) | +| eval-runner-exit | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | +| eval-runs-once-per-cycle | A | 2026-08-31 02:05 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | +| execution-target-contract | A | 2026-08-31 02:05 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | +| get-oh-bootstrap | A | 2026-08-31 02:05 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | +| git-skill | A | 2026-08-31 02:05 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | +| harness-audit-empty-output-gate | A | 2026-08-31 02:05 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | +| harness-ci-core-paths | A | 2026-08-31 02:05 | PASS | #165 — core sandbox config files must trigger harness CI | +| harness-ci-hooks-paths | A | 2026-08-31 02:05 | PASS | issue #202 — credential/security hook changes must trigger harness CI | +| harness-home-provisioning | A | 2026-08-31 02:05 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | +| harness-yaml-migration | A | 2026-08-31 02:05 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | +| health-check-docker-stats | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | +| health-check-socket-degrade | A | 2026-08-31 02:05 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | +| heartbeat-logging-contract | A | 2026-08-31 02:05 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | +| image-seed-hygiene | A | 2026-08-31 02:05 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | +| markitdown-wiki-ingest | A | 2026-08-31 02:05 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | +| next-dev-prod | A | 2026-08-31 02:05 | SKIPPED | retro lesson 2026-06-04 | +| oh-compose-env-wiring | A | 2026-08-31 02:05 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | +| oh-config-surfaces | A | 2026-08-31 02:05 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | +| oh-destroy-guard | A | 2026-08-31 02:05 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | +| oh-devcontainer-restructure | A | 2026-08-31 02:05 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | +| oh-home-mount | A | 2026-08-31 02:05 | PASS | issue #898 (single $HOME mount) 2026-08-30 | +| oh-image-only-deploy | A | 2026-08-31 02:05 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | +| oh-init-headless-config | A | 2026-08-31 02:05 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | +| oh-init-scaffold | A | 2026-08-31 02:05 | PASS | issue #531 Phase 2 | +| oh-lifecycle-surface | A | 2026-08-31 02:05 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | +| oh-npm-package | A | 2026-08-31 02:05 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | +| oh-payload-manifest | A | 2026-08-31 02:05 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | +| oh-sandbox-image-mode | A | 2026-08-31 02:05 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | +| oh-shipped-repo-overridable | A | 2026-08-31 02:05 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | +| oh-standalone-lifecycle | A | 2026-08-31 02:05 | PASS | issue #564 | +| oh-update | A | 2026-08-31 02:05 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | +| operator-config-guard | A | 2026-08-31 02:05 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | +| pnpm-audit-ci-gate | A | 2026-08-31 02:05 | PASS | issue #171 — pnpm security audits must run in CI | +| post-bridge-publish-confirmation | A | 2026-08-31 02:05 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | +| prd-output-path-contract | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-19 | +| prompt-miner-schema-compat | A | 2026-08-31 02:05 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | +| prompt-miner-symlink-entrypoint | A | 2026-08-31 02:05 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | +| prompt-miner-weakness-record | A | 2026-08-31 02:05 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | +| protected-path-deletion | A | 2026-08-31 02:05 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | +| protected-paths-resolve | A | 2026-08-31 02:05 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | +| registry-portability-gate | A | 2026-08-31 02:05 | PASS | issue #758 | +| registry-portability | A | 2026-08-31 02:05 | SKIPPED | issue #758 | +| retro-deterministic-contract | A | 2026-08-31 02:05 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | +| rl-delegation-write-worker | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | +| rlm-context-budget | A | 2026-08-31 02:05 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | +| runtime-preflight-gate | A | 2026-08-31 02:05 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | +| sandbox-boot-guard-ci | A | 2026-08-31 02:05 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | +| sandbox-node-base | A | 2026-08-31 02:05 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | +| skill-paths | A | 2026-08-31 02:05 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | +| skills-dir-clean | A | 2026-08-31 02:05 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | +| skills-task-tool-coupling | A | 2026-08-31 02:05 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | +| skills-vendored | A | 2026-08-31 02:05 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | +| slack-admin-command-surface | A | 2026-08-31 02:05 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | +| spec-family-contract | A | 2026-08-31 02:05 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | +| spec-ready-finalization | A | 2026-08-31 02:05 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | +| ste-checker-contract | A | 2026-08-31 02:05 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | +| submitted-by-trailers | A | 2026-08-31 02:05 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | +| sync-skill-contract | A | 2026-08-31 02:05 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | +| tool-catalog-boundary | A | 2026-08-31 02:05 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | +| version-parity | A | 2026-08-31 02:05 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | +| weigh-scorer-contract | A | 2026-08-31 02:05 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | +| wiki-readme-index | A | 2026-08-31 02:05 | PASS | issue #132 — wiki README index drift guard | +| workflow-boundaries | A | 2026-08-31 02:05 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | +| worktrees-layout | A | 2026-08-31 02:05 | PASS | issue #872 | diff --git a/.oh/evals/probes/harness-home-provisioning.sh b/.oh/evals/probes/harness-home-provisioning.sh index 17f70163..0799c9cd 100755 --- a/.oh/evals/probes/harness-home-provisioning.sh +++ b/.oh/evals/probes/harness-home-provisioning.sh @@ -2,9 +2,12 @@ # tier: A # source: #902 — `oh harness install` must work from inside the sandbox, where # sudo has no NOPASSWD, so default harnesses install into the home mount +# source: #904 — the image must not bake a default harness, or the boot-time +# install path is dead code that CI and a normal boot both skip # desc: every kind:"default" harness installs as the sandbox user into -# NPM_USER_PREFIX, claude-code keeps its postinstall, and the boot path -# carries the OH_PROVISION_HARNESSES guard and its provisioner. +# NPM_USER_PREFIX, claude-code keeps its postinstall, no default harness +# package appears in the Dockerfile, and the boot path carries the +# OH_PROVISION_HARNESSES guard and its provisioner. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" @@ -63,43 +66,44 @@ grep -qF 'WARNING: harness provisioning did not complete' "$ENTRY" \ || missing+=("entrypoint.sh: harness provisioning does not warn-and-continue — an offline sandbox must still come up as a usable shell") [[ -x $PROVISIONER ]] \ || missing+=(".oh/scripts/provision-harnesses.sh: missing or not executable") -run_block_with() { - awk -v needle="$1" ' - function flush() { - if (index(buf, needle)) print buf - buf = "" - } - /^RUN / { buf = $0; cont = ($0 ~ /\\$/); if (!cont) flush(); next } - cont { buf = buf "\n" $0; cont = ($0 ~ /\\$/); if (!cont) flush() } - ' "$DOCKERFILE" +# #904: the image must not bake any kind:"default" harness. The install target is +# the home mount, so a copy under /usr/lib/node_modules shadows it with one no +# running sandbox can upgrade — and, worse, makes the boot-time install path +# dead code that never runs and never gets tested. The package names come from +# the catalog itself, so this cannot drift. +strip_dockerfile_comments() { + grep -vE '^[[:space:]]*#' "$DOCKERFILE" } -BAKE_GATE='if [ "${BAKE_HARNESSES}" = "true" ]' +DOCKERFILE_CODE=$(strip_dockerfile_comments) -stage_body() { - awk -v stage="$1" ' - /^FROM / { inb = ($0 ~ ("AS " stage "$")); next } - inb - ' "$DOCKERFILE" -} - -for stage in base home; do - stage_body "$stage" | grep -qE '^ARG BAKE_HARNESSES' \ - || missing+=("Dockerfile: the $stage stage does not declare ARG BAKE_HARNESSES — ARG is stage-scoped, so the build arg is empty there and every \${BAKE_HARNESSES} test in that stage reads as unset") -done +pkgs=0 +while IFS= read -r entry; do + [[ $entry == *'kind: "default"'* ]] || continue + id=$(sed -n 's/.*id: "\([^"]*\)".*/\1/p' <<<"$entry") + # The package specifier is the last element of installArgv. Read it from that + # array alone — `binary` and `verifyArgv` also hold bare names, and matching + # those would test the wrong string ("claude" appears in the Dockerfile's + # shell alias; "@anthropic-ai/claude-code" is what must not). + argv=$(sed -n 's/.*installArgv: \[\(.*\)\], *installUser.*/\1/p' <<<"$entry") + pkg=$(grep -oE '"[^"]+"' <<<"$argv" | tr -d '"' | tail -1) + if [[ -z $pkg || $pkg == -* ]]; then + missing+=("harnesses/catalog.ts: could not read an install package out of default harness \"$id\" — the no-bake check cannot be applied to it") + continue + fi + pkgs=$((pkgs + 1)) + if grep -qF -- "$pkg" <<<"$DOCKERFILE_CODE"; then + missing+=("Dockerfile: names $pkg — default harness \"$id\" is baked into the image again; it belongs to .oh/scripts/provision-harnesses.sh, which installs it into $PREFIX at boot") + fi +done <<<"$entries" -AGENTS_BLOCK=$(run_block_with 'read -ra agents') -if [[ -z $AGENTS_BLOCK ]]; then - missing+=("Dockerfile: found no RUN that loops over \$AGENTS — the probe cannot tell whether BAKE_HARNESSES gates the bake") -elif [[ $AGENTS_BLOCK != *"$BAKE_GATE"* ]]; then - missing+=("Dockerfile: the RUN that installs \$AGENTS does not test \${BAKE_HARNESSES} — ARG BAKE_HARNESSES is declared but dead, so BAKE_HARNESSES=false still bakes the agent CLIs") +if ((pkgs == 0)); then + echo "SKIPPED: parsed no install package out of any kind:\"default\" catalog entry, so the no-bake check would pass vacuously" >&2 + exit 2 fi -PI_BLOCK=$(run_block_with '--ignore-scripts @earendil-works/pi-coding-agent') -if [[ -z $PI_BLOCK ]]; then - missing+=("Dockerfile: found no RUN that bakes pi into \$NPM_USER_PREFIX — the probe cannot tell whether BAKE_HARNESSES gates it") -elif [[ $PI_BLOCK != *"$BAKE_GATE"* ]]; then - missing+=("Dockerfile: the RUN that bakes pi does not test \${BAKE_HARNESSES} — ARG is stage-scoped, so BAKE_HARNESSES=false unbakes claude and codex but leaves pi in the image") +if grep -qE '^ARG (BAKE_HARNESSES|AGENTS)=' <<<"$DOCKERFILE_CODE"; then + missing+=("Dockerfile: ARG BAKE_HARNESSES/AGENTS is back — a build-arg that re-bakes the default harnesses is a dormant path that reintroduces the shadowed install and un-exercises the boot provisioner") fi if ((${#missing[@]})); then @@ -107,4 +111,4 @@ if ((${#missing[@]})); then exit 1 fi -echo "PASS: all $defaults default harnesses install as the sandbox user into $PREFIX, and the boot path provisions them" >&2 +echo "PASS: all $defaults default harnesses install as the sandbox user into $PREFIX, none of the $pkgs packages is baked into the image, and the boot path provisions them" >&2 diff --git a/.oh/evals/probes/sandbox-boot-guard-ci.sh b/.oh/evals/probes/sandbox-boot-guard-ci.sh index fc6dd713..e603e14b 100755 --- a/.oh/evals/probes/sandbox-boot-guard-ci.sh +++ b/.oh/evals/probes/sandbox-boot-guard-ci.sh @@ -51,7 +51,34 @@ has '--tag "sandbox-${SANDBOX_NAME}"' "compose image tag for smoke boot" has 'bash .oh/scripts/sandbox-boot-smoke.sh' "boot smoke healthcheck invocation" has 'name: Validate sandbox compose and image build' "the named boot guard job" has 'bash .oh/scripts/verify-sandbox-image.sh' "reusable image verifier invocation" -has 'BOOT_SMOKE_TIMEOUT_SECONDS: "900"' "bounded boot smoke timeout" +# The smoke deadline must clear the compose healthcheck's own unhealthy deadline +# (start_period + interval x retries), or the smoke times out before the boot it +# is measuring has had its full allowance. Derive both sides — pinning a literal +# lets a start_period bump silently invert the relationship. +smoke_timeout=$(grep -Eo 'BOOT_SMOKE_TIMEOUT_SECONDS: *"?[0-9]+' <<<"$text" | grep -Eo '[0-9]+$' | head -1) +if [[ -z $smoke_timeout ]]; then + missing+=("bounded boot smoke timeout (no BOOT_SMOKE_TIMEOUT_SECONDS)") +else + COMPOSE_FILE="$ROOT/.devcontainer/docker-compose.yml" + hc=$(awk '/^ *healthcheck:/ {inb=1} inb && /^ *(interval|retries|start_period):/ {print} inb && /^ *restart:/ {inb=0}' "$COMPOSE_FILE") + interval=$(grep -Eo 'interval: *[0-9]+' <<<"$hc" | grep -Eo '[0-9]+' | head -1) + retries=$(grep -Eo 'retries: *[0-9]+' <<<"$hc" | grep -Eo '[0-9]+' | head -1) + start_period=$(grep -Eo 'start_period: *[0-9]+' <<<"$hc" | grep -Eo '[0-9]+' | head -1) + if [[ -z $interval || -z $retries || -z $start_period ]]; then + missing+=("could not read the sandbox healthcheck window out of .devcontainer/docker-compose.yml") + else + deadline=$((start_period + interval * retries)) + if ((smoke_timeout <= deadline)); then + missing+=("BOOT_SMOKE_TIMEOUT_SECONDS=$smoke_timeout does not clear the healthcheck unhealthy deadline of ${deadline}s (start_period ${start_period}s + ${interval}s x ${retries}) — the smoke would time out before the boot it measures") + fi + fi +fi + +# #904: boot-time harness provisioning is exercised nowhere else. Turning it off +# here to save CI minutes would restore it to untested dead code. +if grep -Eq 'OH_PROVISION_HARNESSES: *"?false' <<<"$text"; then + missing+=("the boot guard disables OH_PROVISION_HARNESSES — this job is the only place the boot-time harness install runs") +fi has 'Sandbox boot guard only' "comment explaining non-release intent" if grep -Eq 'docker[[:space:]]+push|--push([[:space:]]|$)|docker/login-action|docker/login|ghcr\.io|[[:alnum:]._-]+\.[[:alnum:]._-]+/.+:.+|packages:[[:space:]]*write|secrets\.' <<<"$text"; then diff --git a/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts b/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts index 59dcca6a..9eceef08 100644 --- a/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts +++ b/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts @@ -16,6 +16,8 @@ function fixture( runtimeExecFails?: boolean; runtimeUid?: string; markerOwner?: string; + harnessProbeFails?: boolean; + noDefaultHarnesses?: boolean; } = {}, ) { const runtimeUid = opts.runtimeUid ?? HOST_UID; @@ -74,6 +76,31 @@ case "$1" in printf '%s\n' ${JSON.stringify(markerOwner)} exit 0 ;; + *"oh harness list --defaults --json"*) + cat <<'JSON' +${ + opts.noDefaultHarnesses + ? "[]" + : `[ + { "id": "claude-code", "title": "Claude Code", "binary": "claude", "kind": "default", "enabled": null, "installed": true, "docs": "x" }, + { "id": "pi", "title": "Pi", "binary": "pi", "kind": "default", "enabled": null, "installed": true, "docs": "x" } +]` +} +JSON + exit 0 + ;; + *"type -P"*) + if [ "${opts.harnessProbeFails ? "1" : "0"}" = "1" ]; then + echo 'is not on PATH under /home/sandbox/.local (type -P gave: /usr/bin/claude)' >&2 + exit 1 + fi + printf '1.2.3\n' + exit 0 + ;; + *"id -u sandbox"*) + printf '%s\n' ${JSON.stringify(runtimeUid)} + exit 0 + ;; esac echo 'sandbox healthcheck ok' exit 0 @@ -138,6 +165,34 @@ describe("sandbox boot smoke", () => { expect(result.stdout).toContain( `sandbox user, bind mount, and sandbox-created files all resolve to ${HOST_UID}:${HOST_GID}`, ); + expect(dockerCalls).toContain("oh harness list --defaults --json"); + expect(dockerCalls).toContain("type -P"); + expect(result.stdout).toContain("claude-code provisioned at boot -> 1.2.3"); + expect(result.stdout).toContain("pi provisioned at boot -> 1.2.3"); + }); + + // #904 deleted the image bake, so this assertion is the only thing standing + // between a silently broken boot-time install and a green pipeline. + it("fails when a default harness was not provisioned into the home mount", () => { + const fx = fixture({ harnessProbeFails: true }); + + const result = runSmoke(fx); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "default harness 'claude-code' was not provisioned into the home mount at boot", + ); + expect(result.stderr).toContain("type -P gave: /usr/bin/claude"); + expect(readFileSync(fx.composeLog, "utf8")).toContain("down -v --remove-orphans"); + }); + + it("refuses to pass vacuously when the catalog reports no default harnesses", () => { + const fx = fixture({ noDefaultHarnesses: true }); + + const result = runSmoke(fx); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('reported no kind:"default" harnesses'); }); it("fails when the runtime sandbox user does not match the checkout owner", () => { diff --git a/.oh/scripts/__tests__/sandbox-healthcheck.test.ts b/.oh/scripts/__tests__/sandbox-healthcheck.test.ts index e5ae10e3..27b36ff2 100644 --- a/.oh/scripts/__tests__/sandbox-healthcheck.test.ts +++ b/.oh/scripts/__tests__/sandbox-healthcheck.test.ts @@ -136,7 +136,13 @@ describe("sandbox healthcheck", () => { expect(compose).toContain("healthcheck:"); expect(compose).toContain("/home/sandbox/harness/.oh/scripts/sandbox-healthcheck.sh"); - expect(compose).toContain("start_period: 300s"); + // Boot installs the default harnesses into the home mount (#904), bounded + // by OH_PROVISION_HARNESSES_TIMEOUT (180s). The start period has to cover + // that plus the rest of boot, so assert the floor rather than a literal + // that a reduction could slip past. + const startPeriod = /start_period: (\d+)s/.exec(compose); + expect(startPeriod, "compose declares no healthcheck start_period").not.toBeNull(); + expect(Number(startPeriod![1])).toBeGreaterThanOrEqual(600); }); it("delegates tmux checks to the sandbox user when Docker invokes as root", () => { diff --git a/.oh/scripts/__tests__/verify-sandbox-image.test.ts b/.oh/scripts/__tests__/verify-sandbox-image.test.ts index 11ca5ec8..0ce05a8d 100644 --- a/.oh/scripts/__tests__/verify-sandbox-image.test.ts +++ b/.oh/scripts/__tests__/verify-sandbox-image.test.ts @@ -23,6 +23,9 @@ type Overrides = Partial<{ missingTool: string; nonVersionTool: string; platformWarning: string; + bakedHarnesses: boolean; + noDefaultHarnesses: boolean; + harnessCatalogFails: boolean; }>; function fixture(o: Overrides = {}) { @@ -44,6 +47,9 @@ function fixture(o: Overrides = {}) { missingTool: "", nonVersionTool: "", platformWarning: "", + bakedHarnesses: false, + noDefaultHarnesses: false, + harnessCatalogFails: false, ...o, }; @@ -62,6 +68,22 @@ case "$cmd" in "pnpm --version") printf '%s\\n' ${JSON.stringify(v.pnpm)} ;; "herdr --version") printf '%s\\n' ${JSON.stringify(v.herdr)} ;; *sha256sum*) printf '%s /usr/local/bin/herdr\\n' ${JSON.stringify(v.herdrSha)} ;; + *"oh harness list --defaults --json"*) + if [ "${v.harnessCatalogFails ? "1" : "0"}" = "1" ]; then + echo 'not an OpenHarness-equipped repo' >&2 + exit 1 + fi + cat <<'JSON' +${ + v.noDefaultHarnesses + ? "[]" + : `[ + { "id": "claude-code", "binary": "claude", "kind": "default", "installed": ${v.bakedHarnesses} }, + { "id": "pi", "binary": "pi", "kind": "default", "installed": false } +]` +} +JSON + ;; *) if [ -n ${JSON.stringify(v.missingTool)} ] && [ "$cmd" = ${JSON.stringify(v.missingTool)} ]; then echo 'command not found' >&2 @@ -175,4 +197,35 @@ describe("verify-sandbox-image", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("matches the arm64 (aarch64) Dockerfile checksum pin"); }); + + // #904: the default harnesses moved out of the image and into the boot path. + // A baked copy under /usr/lib/node_modules shadows the home-mount install and + // silently un-exercises the provisioner, so the image must not carry one. + it("passes an image that bakes no default harness", () => { + const result = run(fixture()); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("no default harness is baked into the image"); + }); + + it("rejects an image that bakes a default harness", () => { + const result = run(fixture({ bakedHarnesses: true })); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("the image ships baked default harnesses: claude-code (claude)"); + }); + + it("refuses to pass vacuously when the image catalog lists no default harness", () => { + const result = run(fixture({ noDefaultHarnesses: true })); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("would pass vacuously"); + }); + + it("fails loudly when the harness catalog cannot be read out of the image", () => { + const result = run(fixture({ harnessCatalogFails: true })); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("could not read the harness catalog from the image"); + }); }); diff --git a/.oh/scripts/sandbox-boot-smoke.sh b/.oh/scripts/sandbox-boot-smoke.sh index c51bcbb9..08046939 100755 --- a/.oh/scripts/sandbox-boot-smoke.sh +++ b/.oh/scripts/sandbox-boot-smoke.sh @@ -81,6 +81,78 @@ verify_bind_ownership() { echo "sandbox boot smoke: sandbox user, bind mount, and sandbox-created files all resolve to $host_uid:$host_gid" } +# Under emulation `docker exec` can prefix output with a platform warning; take +# the first line that actually carries content. +first_real_line() { + grep -vE "^WARNING: The requested image's platform" | grep -m1 -E '[^[:space:]]' || true +} + +# The default harnesses are no longer baked into the image (#904); the boot path +# installs them into the home mount. That install therefore runs on EVERY fresh +# boot, and nothing else in CI exercises it — this is its only oracle. Assert the +# outcome, not the log line: each default harness must resolve to a real binary +# under NPM_USER_PREFIX, owned by the sandbox user, that prints its own version. +verify_default_harnesses() { + local cid="$1" + local prefix="${NPM_USER_PREFIX:-/home/sandbox/.local}" + local states ids binary sandbox_uid out line + + if ! command -v jq >/dev/null 2>&1; then + echo "sandbox boot smoke failed: jq is required on the runner to read the harness catalog JSON" >&2 + return 1 + fi + + if ! states=$(docker exec -u sandbox "$cid" bash -lc 'oh harness list --defaults --json' 2>/tmp/sandbox-boot-smoke-harness.err); then + echo "sandbox boot smoke failed: 'oh harness list --defaults --json' did not run in the booted sandbox" >&2 + cat /tmp/sandbox-boot-smoke-harness.err >&2 || true + return 1 + fi + + ids=$(jq -r '.[] | select(.kind == "default") | .id' <<<"$states") + if [ -z "$ids" ]; then + echo "sandbox boot smoke failed: the harness catalog reported no kind:\"default\" harnesses, so this check would pass vacuously" >&2 + return 1 + fi + + sandbox_uid=$(docker exec "$cid" id -u sandbox) + + local failed=0 + while IFS= read -r id; do + [ -n "$id" ] || continue + binary=$(jq -r --arg id "$id" '.[] | select(.id == $id) | .binary' <<<"$states") + if [ -z "$binary" ] || [ "$binary" = "null" ]; then + echo "sandbox boot smoke failed: default harness '$id' declares no binary to check" >&2 + failed=1 + continue + fi + if ! out=$(docker exec -u sandbox "$cid" bash -lc " + set -e + path=\$(type -P '$binary') + case \"\$path\" in + $prefix/*) ;; + *) echo \"is not on PATH under $prefix (type -P gave: '\$path')\" >&2; exit 1 ;; + esac + owner=\$(stat -Lc %u \"\$path\") + [ \"\$owner\" = '$sandbox_uid' ] || { echo \"binary is owned by uid \$owner, not sandbox ($sandbox_uid)\" >&2; exit 1; } + \"\$path\" --version + " 2>&1); then + echo "sandbox boot smoke failed: default harness '$id' was not provisioned into the home mount at boot" >&2 + printf ' %s\n' "$out" >&2 + failed=1 + continue + fi + line=$(first_real_line <<<"$out") + if ! grep -Eq '(^|[^[:alnum:]])v?[0-9]+([.][0-9]+)+([^[:alnum:]]|$)' <<<"$line"; then + echo "sandbox boot smoke failed: '$binary --version' printed no numeric version: $line" >&2 + failed=1 + continue + fi + echo "sandbox boot smoke: $id provisioned at boot -> $line" + done <<<"$ids" + + [ "$failed" = "0" ] +} + trap teardown EXIT # shellcheck disable=SC2086 # BOOT_SMOKE_UP_ARGS is an intentional argv fragment for CI tuning. @@ -106,7 +178,11 @@ while [ "$(date +%s)" -le "$end" ]; do status_diagnostics "$cid" exit 1 fi - echo "sandbox boot smoke ok: $SERVICE ($cid) passed $HEALTH_CMD, Herdr runtime, and bind-ownership checks" + if ! verify_default_harnesses "$cid"; then + status_diagnostics "$cid" + exit 1 + fi + echo "sandbox boot smoke ok: $SERVICE ($cid) passed $HEALTH_CMD, Herdr runtime, bind-ownership, and boot-provisioned harness checks" exit 0 fi last_status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}' "$cid" 2>/dev/null || echo "inspect-failed") diff --git a/.oh/scripts/verify-sandbox-image.sh b/.oh/scripts/verify-sandbox-image.sh index c875013e..18342b2a 100755 --- a/.oh/scripts/verify-sandbox-image.sh +++ b/.oh/scripts/verify-sandbox-image.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Verify a built sandbox image: base distribution, apt suites, the sandbox # UID/GID contract, the Node/pnpm pins, the Herdr checksum, and version output -# from every required default tool. Usage: verify-sandbox-image.sh +# from every required default tool, and that no kind:"default" harness is baked +# into it. Usage: verify-sandbox-image.sh set -euo pipefail @@ -137,6 +138,32 @@ for tool in "gh --version" "docker --version" "docker compose version" \ fi done +# The image must NOT ship the default harnesses (#904). They are the in-sandbox +# CLI's responsibility and are installed into the home mount at boot, so a +# default harness found here means the bake came back and the home mount's copy +# is shadowed by an unupgradable one under /usr/lib/node_modules. The catalog in +# the image is the source of truth for which ids are default, so this cannot +# drift from harnesses/catalog.ts. +if defaults_json=$(run 'cd /opt/oh-seed && OH_EXECUTION_TARGET=local oh harness list --defaults --json' 2>/tmp/verify-sandbox-defaults.err); then + if command -v jq >/dev/null 2>&1; then + default_ids=$(jq -r '.[] | select(.kind == "default") | .id' <<<"$defaults_json") + if [ -z "$default_ids" ]; then + fail "the image's harness catalog reports no kind:\"default\" harnesses — the unbaked-image check would pass vacuously" + else + baked=$(jq -r '.[] | select(.kind == "default" and .installed == true) | "\(.id) (\(.binary))"' <<<"$defaults_json") + if [ -n "$baked" ]; then + fail "the image ships baked default harnesses: $(tr '\n' ' ' <<<"$baked")— these must be provisioned into /home/sandbox/.local at boot, not baked" + else + ok "no default harness is baked into the image ($(tr '\n' ' ' <<<"$default_ids"))" + fi + fi + else + fail "jq is required to read the image's harness catalog JSON" + fi +else + fail "could not read the harness catalog from the image: $(cat /tmp/verify-sandbox-defaults.err 2>/dev/null | head -3)" +fi + if ((${#failures[@]})); then printf '\nverify-sandbox-image: %d check(s) failed\n' "${#failures[@]}" >&2 printf ' - %s\n' "${failures[@]}" >&2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecce67e..6d2b6452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,16 @@ Update policy and release automation live in [`/git`](.claude/skills/git/SKILL.m ### Changed - **BREAKING:** Persist the sandbox home through one `/home/sandbox` mount, not eleven per-tool volumes; set `storage.homePath` for a host path, else `_workspace` ([#898](https://github.com/mifunedev/openharness/issues/898)). - Shrink the sandbox image ~540 MB: drop build caches from the baked home seed, stage the seed once via a builder stage, and keep untracked build output out of the build context ([#900](https://github.com/mifunedev/openharness/issues/900)). +- **BREAKING:** Stop baking Claude Code, Codex, and Pi into the image; boot installs them into the home mount, so a first boot needs network and runs 60-180s longer ([#904](https://github.com/mifunedev/openharness/issues/904)). ### Removed +- Remove the `BAKE_HARNESSES` and `AGENTS` build args along with the image bake they gated; the harness catalog is the only source of truth for what gets installed ([#904](https://github.com/mifunedev/openharness/issues/904)). - **BREAKING:** Retire the `projectRoot` / `OH_PROJECT_ROOT` config knob — the checkout is fixed at `/home/sandbox/harness`, nested inside the home mount ([#898](https://github.com/mifunedev/openharness/issues/898)). ### Added - Provision the default harnesses into `/home/sandbox/.local` at boot, gated by `OH_PROVISION_HARNESSES`, so `oh harness install` also works from inside the sandbox ([#902](https://github.com/mifunedev/openharness/issues/902)). - Add `oh-home-mount.sh`, a tier-A probe holding the single-`$HOME`-mount contract: one mount per compose file, the baked `/opt/home-seed`, and the checkout prune that replaces `-xdev` ([#898](https://github.com/mifunedev/openharness/issues/898)). +- Assert boot-provisioned harnesses in the boot smoke and reject a baked default harness in `verify-sandbox-image.sh`, so CI exercises the install path ([#904](https://github.com/mifunedev/openharness/issues/904)). - Add `skills-task-tool-coupling.sh`, a tier-A probe holding the canonical skill pack and the sandbox in agreement about the Claude-Code-only task tools ([#886](https://github.com/mifunedev/openharness/issues/886)). ### Fixed diff --git a/docs/deployment-prebuilt-image.md b/docs/deployment-prebuilt-image.md index 48de044b..b934d978 100644 --- a/docs/deployment-prebuilt-image.md +++ b/docs/deployment-prebuilt-image.md @@ -234,9 +234,16 @@ A healthy boot ends with `Providers OK: …` and `SEED_OK`, and the logs show authoritative — later boots see the `.oh/.image-seeded` marker and skip re-seeding, so your in-container edits persist. +The same first boot also installs the default harnesses (Claude Code, Codex, Pi) +into `/home/sandbox/.local`; they are not baked into the image. Expect the boot +to run 60–180s longer than the `sleep 8` above and to need network — check with +`docker exec "$NAME" bash -lc 'oh harness list --defaults'`. If the registry was +unreachable the container still comes up; re-run +`docker exec "$NAME" bash -lc 'bash /home/sandbox/harness/.oh/scripts/provision-harnesses.sh'`. + ```bash # ── 4. Attach an interactive shell (once the container is stable) ── -# Optional: block until the healthcheck reports healthy (start_period ~300s). +# Optional: block until the healthcheck reports healthy (start_period ~600s). until [ "$(docker inspect -f '{{.State.Health.Status}}' "$NAME" 2>/dev/null)" = healthy ]; do echo "waiting for $NAME to become healthy…"; sleep 5 done diff --git a/docs/harnesses/opencode.md b/docs/harnesses/opencode.md index ea3413bf..c6bc5bfb 100644 --- a/docs/harnesses/opencode.md +++ b/docs/harnesses/opencode.md @@ -4,7 +4,7 @@ title: "OpenCode" # OpenCode -OpenCode is a terminal coding agent that can run interactively or execute one-shot tasks. It is an optional image-level runtime in Open Harness; the default sandbox image ships Claude Code, Codex, and Pi only. +OpenCode is a terminal coding agent that can run interactively or execute one-shot tasks. It is an optional image-level runtime in Open Harness; the default harnesses — Claude Code, Codex, and Pi — are provisioned into `~/.local` at boot instead of being baked into the image. ## Install (optional) diff --git a/docs/harnesses/overview.md b/docs/harnesses/overview.md index d49b2954..23bb0eba 100644 --- a/docs/harnesses/overview.md +++ b/docs/harnesses/overview.md @@ -76,7 +76,7 @@ prime-agent --version # Prime Agent (not preinstalled — oh harness install p ## Authentication -Open Harness ships Claude Code, Codex, and Pi in the default image. Authenticate at least one default harness before use; authenticate optional harnesses after enabling their install flags: +Open Harness provisions Claude Code, Codex, and Pi into `~/.local` on first boot rather than baking them into the image. Authenticate at least one default harness before use; authenticate optional harnesses after enabling their install flags: - **Claude Code**: run `claude` and follow the OAuth prompt (see [Claude Code](./claude-code.md)). - **Codex**: run `codex login` (see [Codex](./codex.md)). diff --git a/docs/installation.md b/docs/installation.md index 83372748..3c07475f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -280,7 +280,25 @@ Docker's apt repository tracks the `trixie` suite. Cloudflare's stays on `bookwo ### AI agent CLIs -Default CLIs are always present. Optional CLIs are excluded from the default image; `oh harness install ` flips the matching `install.*` field in `oh.json` and installs it. +Default CLIs are not baked into the image. The entrypoint runs +`.oh/scripts/provision-harnesses.sh` on every boot, which installs any missing +default harness into `~/.local` — inside the home mount — as the `sandbox` user. +That is what makes `oh harness install ` able to upgrade one in place: a copy +under `/usr/lib/node_modules` would be root-owned and unwritable from a running +sandbox. Consequences worth knowing: + +- A **first boot on a fresh home mount needs network** and takes roughly 60–180s + longer. The compose healthcheck's `start_period` is 600s to cover it. +- If the registry is unreachable the sandbox still comes up as a usable shell, + with a warning and no agent CLIs. Re-run + `bash .oh/scripts/provision-harnesses.sh` once you have network. +- An existing install is never replaced, so the provisioner is a no-op on every + boot after the first. Upgrade deliberately with `oh harness install `. +- npm's cache now lives in the home mount at `~/.npm` and grows across upgrades. + `npm cache clean --force` reclaims it. +- Set `OH_PROVISION_HARNESSES=false` to skip the step entirely. + +Optional CLIs are excluded from the default image; `oh harness install ` flips the matching `install.*` field in `oh.json` and installs it. | Tool | Command | Source | Status | |------|---------|--------|--------| @@ -305,8 +323,8 @@ Default CLIs are always present. Optional CLIs are excluded from the default ima ### DevOps & infrastructure `oh tool list` reports which of these are present, and `oh tool status ` -adds a version where the tool has a verified version flag. They are baked into -the image, so there is nothing to install. +adds a version where the tool has a verified version flag. Unlike the agent +CLIs above, these are baked into the image, so there is nothing to install. | Tool | Purpose | |------|---------| diff --git a/docs/quickstart.md b/docs/quickstart.md index c3740163..024d73cd 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -119,9 +119,14 @@ continue to run independently under tmux. ## Set up agents inside Herdr -The default sandbox ships with Claude Code, Codex, and Pi. OpenCode, -DeepAgents, Hermes, and Grok Build are optional image-level installs; T3 Code runs on -demand via the `/t3` skill or direct `npx`. Authenticate at least one harness before use. +The sandbox provisions Claude Code, Codex, and Pi into `~/.local` on first boot — +they live in the home mount, not the image, so `oh harness install ` upgrades +them in place without a rebuild. A first boot on a fresh home mount therefore needs +network access and takes a minute or two longer; the sandbox still comes up as a +usable shell if the registry is unreachable, and you can retry with +`bash .oh/scripts/provision-harnesses.sh`. OpenCode, DeepAgents, Hermes, and Grok +Build are optional image-level installs; T3 Code runs on demand via the `/t3` skill +or direct `npx`. Authenticate at least one harness before use. > **Simplest cross-provider login — device mode via `/login`.** The most straightforward path > that works the same across most harnesses: launch the agent in **interactive mode**, run From dfde856b34c661ccdf23ec7f68bc7d3fd53108aa Mon Sep 17 00:00:00 2001 From: ryaneggz Date: Sun, 30 Aug 2026 20:19:14 -0600 Subject: [PATCH 4/5] task: cite the measured first-boot provisioning time --- docs/installation.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 3c07475f..2223e93a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -287,8 +287,9 @@ That is what makes `oh harness install ` able to upgrade one in place: a cop under `/usr/lib/node_modules` would be root-owned and unwritable from a running sandbox. Consequences worth knowing: -- A **first boot on a fresh home mount needs network** and takes roughly 60–180s - longer. The compose healthcheck's `start_period` is 600s to cover it. +- A **first boot on a fresh home mount needs network**. Measured at 21s on a + GitHub Actions runner; budget 60–180s on a slower link. The compose + healthcheck's `start_period` is 600s to cover it. - If the registry is unreachable the sandbox still comes up as a usable shell, with a warning and no agent CLIs. Re-run `bash .oh/scripts/provision-harnesses.sh` once you have network. From cdf7f4242a14d58620dc83cccca75fdb3d311256 Mon Sep 17 00:00:00 2001 From: ryaneggz Date: Sun, 30 Aug 2026 20:43:35 -0600 Subject: [PATCH 5/5] task: move herdr and cloudflared out of the image into the tool catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- .devcontainer/Dockerfile | 21 -- .devcontainer/docker-compose.image-only.yml | 10 +- .devcontainer/docker-compose.yml | 10 +- .devcontainer/entrypoint.sh | 9 +- .github/workflows/sandbox-boot-guard.yml | 4 +- .oh/cli/src/__tests__/harness-catalog.test.ts | 10 +- .oh/cli/src/__tests__/tool-catalog.test.ts | 53 ++++- .oh/cli/src/cli.ts | 14 +- .oh/cli/src/commands/tool.ts | 17 +- .oh/cli/src/lib/tools/catalog.ts | 59 ++++- .oh/evals/RESULTS.md | 206 +++++++++--------- ...rovisioning.sh => default-provisioning.sh} | 76 +++++-- .oh/evals/probes/sandbox-boot-guard-ci.sh | 4 +- .oh/scripts/__tests__/herdr-default.test.ts | 19 +- .../__tests__/sandbox-base-image.test.ts | 24 +- .../__tests__/sandbox-boot-smoke.test.ts | 26 ++- .../__tests__/sandbox-healthcheck.test.ts | 2 +- .../__tests__/verify-sandbox-image.test.ts | 72 +++--- ...ion-harnesses.sh => provision-defaults.sh} | 98 +++++---- .oh/scripts/sandbox-boot-smoke.sh | 37 ++-- .oh/scripts/verify-sandbox-image.sh | 100 +++------ CHANGELOG.md | 3 + docs/deployment-prebuilt-image.md | 2 +- docs/installation.md | 36 +-- docs/integrations/herdr.md | 2 +- docs/quickstart.md | 5 +- 26 files changed, 540 insertions(+), 379 deletions(-) rename .oh/evals/probes/{harness-home-provisioning.sh => default-provisioning.sh} (52%) rename .oh/scripts/{provision-harnesses.sh => provision-defaults.sh} (59%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ffc38e4b..1207ca0e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -28,29 +28,8 @@ RUN install -m 0755 -d /etc/apt/keyrings \ && apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \ && rm -rf /var/lib/apt/lists/* -# Cloudflare publishes no trixie suite (pkg.cloudflare.com/cloudflared/dists/trixie returns HTTP 404); -# the bookworm package is compatible with trixie, so this suite stays pinned to bookworm. -RUN curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ - -o /usr/share/keyrings/cloudflare-main.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main" \ - > /etc/apt/sources.list.d/cloudflared.list \ - && apt-get update && apt-get install -y --no-install-recommends cloudflared \ - && rm -rf /var/lib/apt/lists/* - RUN BUN_INSTALL=/usr/local curl -fsSL https://bun.sh/install | bash -ARG HERDR_VERSION=0.7.4 -RUN case "$(dpkg --print-architecture)" in \ - amd64) herdr_arch=x86_64; herdr_sha=bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059 ;; \ - arm64) herdr_arch=aarch64; herdr_sha=544e0002de42806d1ab64ccdef3a7e7414f24717b0b6b022bc9e57d2eefd26a2 ;; \ - *) echo "Unsupported Herdr architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \ - esac \ - && curl -fsSL "https://github.com/ogulcancelik/herdr/releases/download/v${HERDR_VERSION}/herdr-linux-${herdr_arch}" \ - -o /usr/local/bin/herdr \ - && echo "${herdr_sha} /usr/local/bin/herdr" | sha256sum -c - \ - && chmod 0755 /usr/local/bin/herdr \ - && test "$(herdr --version)" = "herdr ${HERDR_VERSION}" - RUN curl -LsSf https://astral.sh/uv/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh \ && cp /root/.local/bin/uv /usr/local/bin/uv \ && cp /root/.local/bin/uvx /usr/local/bin/uvx diff --git a/.devcontainer/docker-compose.image-only.yml b/.devcontainer/docker-compose.image-only.yml index 1ef9f6b1..6328e8e4 100644 --- a/.devcontainer/docker-compose.image-only.yml +++ b/.devcontainer/docker-compose.image-only.yml @@ -57,11 +57,11 @@ services: interval: 30s timeout: 10s retries: 3 - # Boot now installs the default harnesses into the home mount instead of - # unpacking them from the image (entrypoint.sh, bounded by - # OH_PROVISION_HARNESSES_TIMEOUT). A cold first boot on a fresh home mount - # therefore spends up to 180s on npm before the control plane is reachable. - # 300s left ~120s for the rest of boot; 600s keeps the same headroom. + # Boot installs the default harnesses AND the default tools (herdr, + # cloudflared) into the home mount instead of unpacking them from the + # image (entrypoint.sh, bounded by OH_PROVISION_DEFAULTS_TIMEOUT). A cold + # first boot on a fresh home mount therefore spends up to 240s downloading + # before the control plane is reachable; 600s leaves room for the rest. start_period: 600s restart: unless-stopped diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 3cd0fba0..8a99d972 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -83,11 +83,11 @@ services: interval: 30s timeout: 10s retries: 3 - # Boot now installs the default harnesses into the home mount instead of - # unpacking them from the image (entrypoint.sh, bounded by - # OH_PROVISION_HARNESSES_TIMEOUT). A cold first boot on a fresh home mount - # therefore spends up to 180s on npm before the control plane is reachable. - # 300s left ~120s for the rest of boot; 600s keeps the same headroom. + # Boot installs the default harnesses AND the default tools (herdr, + # cloudflared) into the home mount instead of unpacking them from the + # image (entrypoint.sh, bounded by OH_PROVISION_DEFAULTS_TIMEOUT). A cold + # first boot on a fresh home mount therefore spends up to 240s downloading + # before the control plane is reachable; 600s leaves room for the rest. start_period: 600s restart: unless-stopped diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index cbe342a9..f1d13501 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -150,10 +150,11 @@ if [ -x "$HARNESS/.oh/scripts/link-providers.sh" ]; then fi fi -if [ "${OH_PROVISION_HARNESSES:-true}" = "true" ] \ - && [ -x "$HARNESS/.oh/scripts/provision-harnesses.sh" ]; then - if ! OH_EXECUTION_TARGET=local timeout "${OH_PROVISION_HARNESSES_TIMEOUT:-180}" bash "$HARNESS/.oh/scripts/provision-harnesses.sh"; then - echo "[entrypoint] WARNING: harness provisioning did not complete; run: bash .oh/scripts/provision-harnesses.sh" >&2 +if [ "${OH_PROVISION_DEFAULTS:-true}" = "true" ] \ + && [ -x "$HARNESS/.oh/scripts/provision-defaults.sh" ]; then + if ! OH_EXECUTION_TARGET=local timeout "${OH_PROVISION_DEFAULTS_TIMEOUT:-240}" bash "$HARNESS/.oh/scripts/provision-defaults.sh"; then + echo "[entrypoint] WARNING: default provisioning did not complete; run: bash .oh/scripts/provision-defaults.sh" >&2 + echo "[entrypoint] WARNING: herdr may be unavailable — 'tmux' still works as a fallback multiplexer" >&2 fi fi diff --git a/.github/workflows/sandbox-boot-guard.yml b/.github/workflows/sandbox-boot-guard.yml index f4b4c9f8..1591267e 100644 --- a/.github/workflows/sandbox-boot-guard.yml +++ b/.github/workflows/sandbox-boot-guard.yml @@ -134,8 +134,8 @@ jobs: # This step is the ONLY place the boot-time harness install runs end to end. # It boots on a fresh home volume (the smoke tears down with `down -v`), so - # provision-harnesses.sh does real npm work every run and the smoke asserts - # the result. Do not set OH_PROVISION_HARNESSES=false here to speed it up — + # provision-defaults.sh does real npm work every run and the smoke asserts + # the result. Do not set OH_PROVISION_DEFAULTS=false here to speed it up — # that would return the install path to being untested dead code. - name: Boot sandbox and verify healthcheck (exercises boot-time harness provisioning) env: diff --git a/.oh/cli/src/__tests__/harness-catalog.test.ts b/.oh/cli/src/__tests__/harness-catalog.test.ts index b6424492..47a62bd8 100644 --- a/.oh/cli/src/__tests__/harness-catalog.test.ts +++ b/.oh/cli/src/__tests__/harness-catalog.test.ts @@ -158,8 +158,8 @@ describe("harness catalog", () => { }); it("provisions the default harnesses at boot rather than baking them", () => { - expect(ENTRYPOINT).toContain("OH_PROVISION_HARNESSES"); - expect(ENTRYPOINT).toContain(".oh/scripts/provision-harnesses.sh"); + expect(ENTRYPOINT).toContain("OH_PROVISION_DEFAULTS"); + expect(ENTRYPOINT).toContain(".oh/scripts/provision-defaults.sh"); }); it.each(defaults.map((h) => [h.id, h] as const))( @@ -169,7 +169,7 @@ describe("harness catalog", () => { expect(pkg, `${id} declares no install package`).toMatch(/^(@[^/]+\/)?[^-].*/); expect( DOCKERFILE_CODE, - `${id} is baked into the image; it belongs to provision-harnesses.sh`, + `${id} is baked into the image; it belongs to provision-defaults.sh`, ).not.toContain(pkg); }, ); @@ -180,9 +180,9 @@ describe("harness catalog", () => { it("bounds the boot-path provisioner so an unreachable registry cannot stall the entrypoint", () => { expect(ENTRYPOINT).toMatch( - /timeout "\$\{OH_PROVISION_HARNESSES_TIMEOUT:-\d+\}" bash "\$HARNESS\/\.oh\/scripts\/provision-harnesses\.sh"/, + /timeout "\$\{OH_PROVISION_DEFAULTS_TIMEOUT:-\d+\}" bash "\$HARNESS\/\.oh\/scripts\/provision-defaults\.sh"/, ); - expect(ENTRYPOINT).toContain("WARNING: harness provisioning did not complete"); + expect(ENTRYPOINT).toContain("WARNING: default provisioning did not complete"); }); }); diff --git a/.oh/cli/src/__tests__/tool-catalog.test.ts b/.oh/cli/src/__tests__/tool-catalog.test.ts index 37e483f2..1e72346a 100644 --- a/.oh/cli/src/__tests__/tool-catalog.test.ts +++ b/.oh/cli/src/__tests__/tool-catalog.test.ts @@ -25,8 +25,27 @@ describe("tool catalog shape", () => { ]); }); - it("has exactly one installable tool", () => { - expect(installableToolIds()).toEqual(["agent-browser"]); + it("makes exactly the default and opt-in tools installable", () => { + expect(installableToolIds()).toEqual(["agent-browser", "herdr", "cloudflared"]); + for (const t of TOOL_CATALOG) { + // A kind:"default" tool is provisioned at boot through `oh tool install`, + // so it MUST be installable; a baked-in one must not be. + if (t.kind === "default") expect(t.installArgv, t.id).toBeDefined(); + if (t.kind === "baked-in") expect(t.installArgv, t.id).toBeUndefined(); + } + }); + + // #906: commands/tool.ts installs with stdio:"inherit", so local-target.ts + // picks plain `sudo --` for a root install — and /etc/sudoers.d/sandbox has + // no NOPASSWD. A root-installed default would hang an agent on a password + // prompt, and could not be upgraded by the running sandbox afterwards. + it("installs every default tool as the sandbox user into the home mount", () => { + for (const t of TOOL_CATALOG) { + if (t.kind !== "default") continue; + expect(t.installUser, t.id).toBe("sandbox"); + expect(t.installArgv!.join("\n"), t.id).toContain("NPM_USER_PREFIX"); + expect(t.installArgv!.join("\n"), t.id).toContain("sha256sum -c -"); + } }); it("makes every non-installable tool say why", () => { @@ -49,17 +68,29 @@ describe("tool catalog shape", () => { it("declares a version probe only where the flag is a safe standard", () => { const withVersion = TOOL_CATALOG.filter((t) => t.versionArgv !== undefined).map((t) => t.id); - expect(withVersion).toEqual(["cloudflared", "docker-cli", "gh"]); + expect(withVersion).toEqual(["herdr", "cloudflared", "docker-cli", "gh"]); for (const t of TOOL_CATALOG) { if (t.versionArgv) expect(t.versionArgv, t.id).toEqual([t.binary, "--version"]); } }); it("passes argv arrays with no interpolation this process performs", () => { + // The hazard is a JS template literal that Node expands before the argv + // ever reaches a shell. A `bash -lc` script body legitimately contains + // ${...} for the shell IN the container to expand, so exempt that one + // token and forbid backticks in the catalog source instead. + expect( + read(".oh/cli/src/lib/tools/catalog.ts"), + "a template literal with ${...} would be expanded by Node before any shell sees it", + ).not.toMatch(/`[^`]*\$\{/s); for (const t of TOOL_CATALOG) { for (const argv of [t.installArgv, t.verifyArgv, t.versionArgv]) { if (!argv) continue; - for (const token of argv) expect(token, `${t.id}: ${token}`).not.toContain("${"); + const shellBody = argv[0] === "bash" && argv[1] === "-lc" ? 2 : -1; + argv.forEach((token, i) => { + if (i === shellBody) return; + expect(token, `${t.id}: ${token}`).not.toContain("${"); + }); } } }); @@ -153,8 +184,20 @@ describe("baked-in tools", () => { it("are each actually in the Dockerfile", () => { const dockerfile = read(".devcontainer/Dockerfile"); + const baked = TOOL_CATALOG.filter((t) => t.kind === "baked-in"); + expect(baked.length, "no baked-in tool left to check").toBeGreaterThan(0); + for (const t of baked) { + expect(dockerfile, t.id).toContain(t.binary); + } + }); + + // #906: herdr and cloudflared moved to kind:"default". The inverse of the + // check above — a default tool must NOT be in the Dockerfile — lives in + // .oh/evals/probes/default-provisioning.sh, which matches on the pinned + // project URL rather than the bare binary name. + it("no longer claims herdr or cloudflared", () => { for (const id of ["herdr", "cloudflared"]) { - expect(dockerfile, id).toContain(id); + expect(findTool(id)!.kind, id).toBe("default"); } }); }); diff --git a/.oh/cli/src/cli.ts b/.oh/cli/src/cli.ts index 733ab131..46886fd3 100644 --- a/.oh/cli/src/cli.ts +++ b/.oh/cli/src/cli.ts @@ -370,7 +370,7 @@ runtime (see \`oh runtime\`) — a headless browser, a tunnel client, the GitHub CLI. Usage: - oh tool list List known tools and their state + oh tool list [--defaults] List known tools and their state oh tool status [name] Show installed state and version oh tool install Install a tool into the sandbox @@ -387,6 +387,7 @@ Flags: --no-persist Live-install only; leave oh.json unchanged --yes Accept a large download without prompting --json Machine-readable output (list/status) + --defaults List only kind:"default" tools (list) Tools: ${toolIds().map((t) => ` ${t}`).join("\n")} @@ -943,6 +944,7 @@ interface ToolArgs { noPersist: boolean; yes: boolean; json: boolean; + defaultsOnly: boolean; subcommand?: "list" | "install" | "status"; name?: string; } @@ -950,6 +952,7 @@ interface ToolArgs { export function parseToolArgs(rest: string[]): ParseResult { const args: ToolArgs = { help: false, persistOnly: false, noPersist: false, yes: false, json: false, + defaultsOnly: false, }; if (rest.length === 0 || isHelpFlag(rest[0])) { return { ok: true, args: { ...args, help: true } }; @@ -961,6 +964,7 @@ export function parseToolArgs(rest: string[]): ParseResult { else if (token === "--no-persist") args.noPersist = true; else if (token === "--yes" || token === "-y") args.yes = true; else if (token === "--json") args.json = true; + else if (token === "--defaults") args.defaultsOnly = true; else if (token.startsWith("-")) { return { ok: false, error: `oh tool: unknown flag "${token}"` }; } else positionals.push(token); @@ -989,6 +993,12 @@ export function parseToolArgs(rest: string[]): ParseResult { error: "oh tool: --persist-only conflicts with --no-persist — pass at most one", }; } + if (args.defaultsOnly && sub !== "list") { + return { + ok: false, + error: `oh tool ${sub}: --defaults applies to \`oh tool list\` only`, + }; + } args.subcommand = sub; if (name !== undefined) args.name = name; @@ -1405,7 +1415,7 @@ async function main(argv: string[]): Promise { stderr: (s) => process.stderr.write(s), }; if (a.subcommand === "list") { - return await runToolList({ json: a.json }, io); + return await runToolList({ json: a.json, defaultsOnly: a.defaultsOnly }, io); } if (a.subcommand === "status") { return await runToolStatus(a.name, { json: a.json }, io); diff --git a/.oh/cli/src/commands/tool.ts b/.oh/cli/src/commands/tool.ts index f181db8e..02af4298 100644 --- a/.oh/cli/src/commands/tool.ts +++ b/.oh/cli/src/commands/tool.ts @@ -13,6 +13,7 @@ import { setInstallFlag, } from "../lib/env-file.js"; import { + defaultTools, findTool, installableToolIds, toolIds, @@ -32,6 +33,7 @@ export interface ToolOptions { cwd?: string; run?: LifecycleRunner; json?: boolean; + defaultsOnly?: boolean; env?: NodeJS.ProcessEnv; } @@ -44,6 +46,7 @@ export interface ToolInstallOptions extends ToolOptions { interface ToolRow { id: string; title: string; + binary: string; kind: string; enabled: boolean | null; installed: boolean | null; @@ -107,9 +110,9 @@ async function collectRows( root: string, run: LifecycleRunner, env?: NodeJS.ProcessEnv, - only?: ToolEntry, + only?: readonly ToolEntry[], ): Promise { - const entries = only ? [only] : [...TOOL_CATALOG]; + const entries = only ? [...only] : [...TOOL_CATALOG]; const target = targetFor(root, run, env); let reachable = false; @@ -125,6 +128,7 @@ async function collectRows( rows.push({ id: entry.id, title: entry.title, + binary: entry.binary, kind: entry.kind, enabled: entry.toolKey === undefined ? null : isInstallFlagEnabled(root, entry.toolKey), @@ -175,7 +179,12 @@ function renderDetail(rows: ToolRow[], io: ToolIO): void { export async function runToolList(opts: ToolOptions, io: ToolIO): Promise { const run = opts.run ?? spawnRunner; const root = resolveProjectRoot(opts.cwd); - const rows = await collectRows(root, run, opts.env); + const rows = await collectRows( + root, + run, + opts.env, + opts.defaultsOnly === true ? defaultTools() : undefined, + ); if (opts.json) { io.stdout(`${JSON.stringify(rows, null, 2)}\n`); } else { @@ -204,7 +213,7 @@ export async function runToolStatus( if (!only) return unknownTool(name, io); } - const rows = await collectRows(root, run, opts.env, only); + const rows = await collectRows(root, run, opts.env, only ? [only] : undefined); if (opts.json) { io.stdout(`${JSON.stringify(only ? rows[0] : rows, null, 2)}\n`); } else { diff --git a/.oh/cli/src/lib/tools/catalog.ts b/.oh/cli/src/lib/tools/catalog.ts index aebb3e84..fac0d638 100644 --- a/.oh/cli/src/lib/tools/catalog.ts +++ b/.oh/cli/src/lib/tools/catalog.ts @@ -1,6 +1,7 @@ export type ToolKind = | "baked-in" + | "default" | "opt-in"; export interface ToolEntry { @@ -42,22 +43,64 @@ export const TOOL_CATALOG: readonly ToolEntry[] = Object.freeze([ Object.freeze({ id: "herdr", title: "Herdr", - kind: "baked-in", + kind: "default", binary: "herdr", verifyArgv: Object.freeze(["bash", "-lc", "command -v herdr >/dev/null"]), - notInstallableReason: - "herdr is installed in the base image with a pinned, sha256-verified binary (.devcontainer/Dockerfile). Rebuild the image to change it.", + versionArgv: Object.freeze(["herdr", "--version"]), + installArgv: Object.freeze([ + "bash", + "-lc", + [ + "set -e", + 'version=0.7.4', + 'case "$(dpkg --print-architecture)" in', + " amd64) arch=x86_64; sha=bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059 ;;", + " arm64) arch=aarch64; sha=544e0002de42806d1ab64ccdef3a7e7414f24717b0b6b022bc9e57d2eefd26a2 ;;", + ' *) echo "no pinned Herdr build for $(dpkg --print-architecture)" >&2; exit 1 ;;', + "esac", + 'prefix="${NPM_USER_PREFIX:-$HOME/.local}"', + 'tmp="$(mktemp -d)"', + "trap 'rm -rf \"$tmp\"' EXIT", + 'curl -fsSL "https://github.com/ogulcancelik/herdr/releases/download/v$version/herdr-linux-$arch" -o "$tmp/herdr"', + 'echo "$sha $tmp/herdr" | sha256sum -c -', + 'install -d "$prefix/bin"', + 'install -m 0755 "$tmp/herdr" "$prefix/bin/herdr"', + 'test "$("$prefix/bin/herdr" --version)" = "herdr $version"', + ].join("\n"), + ]), + installUser: "sandbox", docsPath: TOOLS_DOC, }), Object.freeze({ id: "cloudflared", title: "cloudflared", - kind: "baked-in", + kind: "default", binary: "cloudflared", verifyArgv: Object.freeze(["bash", "-lc", "command -v cloudflared >/dev/null"]), versionArgv: Object.freeze(["cloudflared", "--version"]), - notInstallableReason: - "cloudflared is installed in the base image from Cloudflare's apt repository (.devcontainer/Dockerfile). Rebuild the image to change it.", + installArgv: Object.freeze([ + "bash", + "-lc", + [ + "set -e", + "version=2026.8.2", + 'case "$(dpkg --print-architecture)" in', + " amd64) sha=fcfb02b575a52ca1af2e3267af4e1517bcdeb30ac48c834c69abaed3c0576ad2 ;;", + " arm64) sha=7747d94570fb390cf47dcb4f9555c193c6355cda9793f0d878d9049e5d6a7790 ;;", + ' *) echo "no pinned cloudflared build for $(dpkg --print-architecture)" >&2; exit 1 ;;', + "esac", + 'arch="$(dpkg --print-architecture)"', + 'prefix="${NPM_USER_PREFIX:-$HOME/.local}"', + 'tmp="$(mktemp -d)"', + "trap 'rm -rf \"$tmp\"' EXIT", + 'curl -fsSL "https://github.com/cloudflare/cloudflared/releases/download/$version/cloudflared-linux-$arch" -o "$tmp/cloudflared"', + 'echo "$sha $tmp/cloudflared" | sha256sum -c -', + 'install -d "$prefix/bin"', + 'install -m 0755 "$tmp/cloudflared" "$prefix/bin/cloudflared"', + '"$prefix/bin/cloudflared" --version >/dev/null', + ].join("\n"), + ]), + installUser: "sandbox", docsPath: TOOLS_DOC, }), Object.freeze({ @@ -92,6 +135,10 @@ export function toolIds(): string[] { return TOOL_CATALOG.map((t) => t.id); } +export function defaultTools(): readonly ToolEntry[] { + return TOOL_CATALOG.filter((t) => t.kind === "default"); +} + export function installableToolIds(): string[] { return TOOL_CATALOG.filter((t) => t.installArgv !== undefined).map((t) => t.id); } diff --git a/.oh/evals/RESULTS.md b/.oh/evals/RESULTS.md index 0d1ab988..319a405a 100644 --- a/.oh/evals/RESULTS.md +++ b/.oh/evals/RESULTS.md @@ -6,108 +6,108 @@ probe id; git history is the time series.** Schema and exit-code semantics are i | probe | tier | last-run (UTC) | status | source | |-------|------|----------------|--------|--------| -| advisor-monitored-loop | A | 2026-08-31 02:05 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | -| agent-browser-cli | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | -| agents-identity-contract | A | 2026-08-31 02:05 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | -| artifact-contract-audit | A | 2026-08-31 02:05 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | -| audit-dispatcher-contract | A | 2026-08-31 02:05 | PASS | issue #645 — audit consolidation public taxonomy | -| audit-implementation-behavior | A | 2026-08-31 02:05 | PASS | issue #645 — implementation root/repo/browser behavior | -| audit-pr-acquire | A | 2026-08-31 02:05 | PASS | issue #645 — production PR acquisition behavior | -| audit-pr-classifier | A | 2026-08-31 02:05 | PASS | issue #645 — deterministic focused and queue PR classifier | -| audit-run-root-contract | A | 2026-08-31 02:05 | PASS | issue #645 — executable immutable audit root/run correlation | -| audit-shellcheck-coverage | A | 2026-08-31 02:05 | PASS | issue #645 — private audit scripts require release and CI lint coverage | -| audit-stale-references | A | 2026-08-31 02:05 | PASS | issue #645 — clean-breaking audit migration | -| boot-lint-glob | A | 2026-08-31 02:05 | PASS | issue #90, issue #120 | -| builder-skill-consolidation | A | 2026-08-31 02:05 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | -| capability-benchmark-schema | A | 2026-08-31 02:05 | PASS | issue #167 — capability benchmark instrument | -| cc-safety-net-wiring | A | 2026-08-31 02:05 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | -| changelog-entry-length | A | 2026-08-31 02:05 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | -| cleanup-tasks-scoped-guard | A | 2026-08-31 02:05 | PASS | issue #85 | -| cleanup-tasks-worktree-grooming | A | 2026-08-31 02:05 | PASS | issue #168; issue #327 | -| cli-publish-typecheck-scope | A | 2026-08-31 02:05 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | -| close-issues-on-development | A | 2026-08-31 02:05 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | -| codex-stale-response-retry | A | 2026-08-31 02:05 | PASS | issue #506 — Codex previous_response_not_found RCA | -| compose-config-path-parity | A | 2026-08-31 02:05 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | -| config-schema-parity | A | 2026-08-31 02:05 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | -| context-tier-size-budget | A | 2026-08-31 02:05 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | -| cron-claude-codex-fallback | A | 2026-08-31 02:05 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | -| cron-watchdog | A | 2026-08-31 02:05 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | -| crons-directory-guide | A | 2026-08-31 02:05 | PASS | issue #874 | -| curl-bash-safe-alternatives | A | 2026-08-31 02:05 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | -| datasets-schema | A | 2026-08-31 02:05 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | -| debugmcp-availability | A | 2026-08-31 02:05 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | -| delegate-model-effort-policy | A | 2026-08-31 02:05 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | -| devtcp-hook | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | -| docker-inspect-env-guard | A | 2026-08-31 02:05 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | -| docs-build-fast-path | A | 2026-08-31 02:05 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | -| drift-check-cron-staleness-glob | A | 2026-08-31 02:05 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | -| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 02:05 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | -| eval-ci-gate | A | 2026-08-31 02:05 | PASS | #103 — eval probe suite gated in CI | -| eval-gate | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-11 (eval-gate) | -| eval-results-atomic | A | 2026-08-31 02:05 | PASS | issue #83 (eval-results-atomic-write) | -| eval-runner-exit | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | -| eval-runs-once-per-cycle | A | 2026-08-31 02:05 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | -| execution-target-contract | A | 2026-08-31 02:05 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | -| get-oh-bootstrap | A | 2026-08-31 02:05 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | -| git-skill | A | 2026-08-31 02:05 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | -| harness-audit-empty-output-gate | A | 2026-08-31 02:05 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | -| harness-ci-core-paths | A | 2026-08-31 02:05 | PASS | #165 — core sandbox config files must trigger harness CI | -| harness-ci-hooks-paths | A | 2026-08-31 02:05 | PASS | issue #202 — credential/security hook changes must trigger harness CI | -| harness-home-provisioning | A | 2026-08-31 02:05 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | -| harness-yaml-migration | A | 2026-08-31 02:05 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | -| health-check-docker-stats | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | -| health-check-socket-degrade | A | 2026-08-31 02:05 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | -| heartbeat-logging-contract | A | 2026-08-31 02:05 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | -| image-seed-hygiene | A | 2026-08-31 02:05 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | -| markitdown-wiki-ingest | A | 2026-08-31 02:05 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | -| next-dev-prod | A | 2026-08-31 02:05 | SKIPPED | retro lesson 2026-06-04 | -| oh-compose-env-wiring | A | 2026-08-31 02:05 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | -| oh-config-surfaces | A | 2026-08-31 02:05 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | -| oh-destroy-guard | A | 2026-08-31 02:05 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | -| oh-devcontainer-restructure | A | 2026-08-31 02:05 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | -| oh-home-mount | A | 2026-08-31 02:05 | PASS | issue #898 (single $HOME mount) 2026-08-30 | -| oh-image-only-deploy | A | 2026-08-31 02:05 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | -| oh-init-headless-config | A | 2026-08-31 02:05 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | -| oh-init-scaffold | A | 2026-08-31 02:05 | PASS | issue #531 Phase 2 | -| oh-lifecycle-surface | A | 2026-08-31 02:05 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | -| oh-npm-package | A | 2026-08-31 02:05 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | -| oh-payload-manifest | A | 2026-08-31 02:05 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | -| oh-sandbox-image-mode | A | 2026-08-31 02:05 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | -| oh-shipped-repo-overridable | A | 2026-08-31 02:05 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | -| oh-standalone-lifecycle | A | 2026-08-31 02:05 | PASS | issue #564 | -| oh-update | A | 2026-08-31 02:05 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | -| operator-config-guard | A | 2026-08-31 02:05 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | -| pnpm-audit-ci-gate | A | 2026-08-31 02:05 | PASS | issue #171 — pnpm security audits must run in CI | -| post-bridge-publish-confirmation | A | 2026-08-31 02:05 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | -| prd-output-path-contract | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-19 | -| prompt-miner-schema-compat | A | 2026-08-31 02:05 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | -| prompt-miner-symlink-entrypoint | A | 2026-08-31 02:05 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | -| prompt-miner-weakness-record | A | 2026-08-31 02:05 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | -| protected-path-deletion | A | 2026-08-31 02:05 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | -| protected-paths-resolve | A | 2026-08-31 02:05 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | -| registry-portability-gate | A | 2026-08-31 02:05 | PASS | issue #758 | -| registry-portability | A | 2026-08-31 02:05 | SKIPPED | issue #758 | -| retro-deterministic-contract | A | 2026-08-31 02:05 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | -| rl-delegation-write-worker | A | 2026-08-31 02:05 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | -| rlm-context-budget | A | 2026-08-31 02:05 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | -| runtime-preflight-gate | A | 2026-08-31 02:05 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | -| sandbox-boot-guard-ci | A | 2026-08-31 02:05 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | -| sandbox-node-base | A | 2026-08-31 02:05 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | -| skill-paths | A | 2026-08-31 02:05 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | -| skills-dir-clean | A | 2026-08-31 02:05 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | -| skills-task-tool-coupling | A | 2026-08-31 02:05 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | -| skills-vendored | A | 2026-08-31 02:05 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | -| slack-admin-command-surface | A | 2026-08-31 02:05 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | -| spec-family-contract | A | 2026-08-31 02:05 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | -| spec-ready-finalization | A | 2026-08-31 02:05 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | -| ste-checker-contract | A | 2026-08-31 02:05 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | -| submitted-by-trailers | A | 2026-08-31 02:05 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | -| sync-skill-contract | A | 2026-08-31 02:05 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | -| tool-catalog-boundary | A | 2026-08-31 02:05 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | -| version-parity | A | 2026-08-31 02:05 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | -| weigh-scorer-contract | A | 2026-08-31 02:05 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | -| wiki-readme-index | A | 2026-08-31 02:05 | PASS | issue #132 — wiki README index drift guard | -| workflow-boundaries | A | 2026-08-31 02:05 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | -| worktrees-layout | A | 2026-08-31 02:05 | PASS | issue #872 | +| advisor-monitored-loop | A | 2026-08-31 02:42 | PASS | conversation 2026-06-19 (single-owner implementation workflow, issue #257) | +| agent-browser-cli | A | 2026-08-31 02:42 | PASS | retro lesson 2026-06-07 (agent-browser 0.8.5 CLI) | +| agents-identity-contract | A | 2026-08-31 02:42 | PASS | issue #854 — T3-style root identity, glossary, and skill-owned procedures | +| artifact-contract-audit | A | 2026-08-31 02:42 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | +| audit-dispatcher-contract | A | 2026-08-31 02:42 | PASS | issue #645 — audit consolidation public taxonomy | +| audit-implementation-behavior | A | 2026-08-31 02:42 | PASS | issue #645 — implementation root/repo/browser behavior | +| audit-pr-acquire | A | 2026-08-31 02:42 | PASS | issue #645 — production PR acquisition behavior | +| audit-pr-classifier | A | 2026-08-31 02:42 | PASS | issue #645 — deterministic focused and queue PR classifier | +| audit-run-root-contract | A | 2026-08-31 02:42 | PASS | issue #645 — executable immutable audit root/run correlation | +| audit-shellcheck-coverage | A | 2026-08-31 02:42 | PASS | issue #645 — private audit scripts require release and CI lint coverage | +| audit-stale-references | A | 2026-08-31 02:42 | PASS | issue #645 — clean-breaking audit migration | +| boot-lint-glob | A | 2026-08-31 02:42 | PASS | issue #90, issue #120 | +| builder-skill-consolidation | A | 2026-08-31 02:42 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | +| capability-benchmark-schema | A | 2026-08-31 02:42 | PASS | issue #167 — capability benchmark instrument | +| cc-safety-net-wiring | A | 2026-08-31 02:42 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | +| changelog-entry-length | A | 2026-08-31 02:42 | PASS | conversation 2026-08-24 — CHANGELOG.md grew to 259KB of bullet prose because "one line" was unquantified | +| cleanup-tasks-scoped-guard | A | 2026-08-31 02:42 | PASS | issue #85 | +| cleanup-tasks-worktree-grooming | A | 2026-08-31 02:42 | PASS | issue #168; issue #327 | +| cli-publish-typecheck-scope | A | 2026-08-31 02:42 | PASS | release run 33271077312 — v0.5.0 pushed its GHCR image, then failed to publish | +| close-issues-on-development | A | 2026-08-31 02:42 | PASS | issue #841 (closing keywords never fire because the default branch is main) 2026-08-26 | +| codex-stale-response-retry | A | 2026-08-31 02:42 | PASS | issue #506 — Codex previous_response_not_found RCA | +| compose-config-path-parity | A | 2026-08-31 02:42 | PASS | PR #833 (remove harness.yaml — the wrapper and VS Code "Reopen in Container" paths must resolve the same service) 2026-08-26 | +| config-schema-parity | A | 2026-08-31 02:42 | PASS | PR #833 (one schema file — DOCKER_SOCKET, SANDBOX_SSH, OH_SANDBOX_IMAGE, OH_PULL_POLICY, SKIP_PNPM_INSTALL were consumed but undocumented); rewritten for the oh.json/secrets split by PR #887 | +| context-tier-size-budget | A | 2026-08-31 02:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-007) — the always-on tier was 85,256 B | +| cron-claude-codex-fallback | A | 2026-08-31 02:42 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | +| cron-watchdog | A | 2026-08-31 02:42 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | +| crons-directory-guide | A | 2026-08-31 02:42 | PASS | issue #874 | +| curl-bash-safe-alternatives | A | 2026-08-31 02:42 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | +| datasets-schema | A | 2026-08-31 02:42 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | +| debugmcp-availability | A | 2026-08-31 02:42 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | +| default-provisioning | A | 2026-08-31 02:42 | PASS | #902 — `oh harness install` must work from inside the sandbox, where | +| delegate-model-effort-policy | A | 2026-08-31 02:42 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | +| devtcp-hook | A | 2026-08-31 02:42 | PASS | retro lesson 2026-06-10 (zsh /dev/tcp) | +| docker-inspect-env-guard | A | 2026-08-31 02:42 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | +| docs-build-fast-path | A | 2026-08-31 02:42 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to docs/ | +| drift-check-cron-staleness-glob | A | 2026-08-31 02:42 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | +| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-31 02:42 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | +| eval-ci-gate | A | 2026-08-31 02:42 | PASS | #103 — eval probe suite gated in CI | +| eval-gate | A | 2026-08-31 02:42 | PASS | retro lesson 2026-06-11 (eval-gate) | +| eval-results-atomic | A | 2026-08-31 02:42 | PASS | issue #83 (eval-results-atomic-write) | +| eval-runner-exit | A | 2026-08-31 02:42 | PASS | retro lesson 2026-06-11 (eval-runner-exit) #29 | +| eval-runs-once-per-cycle | A | 2026-08-31 02:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-006) — /eval ran 3x per cycle on the | +| execution-target-contract | A | 2026-08-31 02:42 | PASS | issue #733 (ExecutionTarget contract + Docker Compose adapter) 2026-08-10 | +| get-oh-bootstrap | A | 2026-08-31 02:42 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | +| git-skill | A | 2026-08-31 02:42 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | +| harness-audit-empty-output-gate | A | 2026-08-31 02:42 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | +| harness-ci-core-paths | A | 2026-08-31 02:42 | PASS | #165 — core sandbox config files must trigger harness CI | +| harness-ci-hooks-paths | A | 2026-08-31 02:42 | PASS | issue #202 — credential/security hook changes must trigger harness CI | +| harness-yaml-migration | A | 2026-08-31 02:42 | PASS | PR #833 (migrate-harness-yaml.sh — append / uncomment-in-place / preserve / overwrite, plus a silent no-op second run) 2026-08-26 | +| health-check-docker-stats | A | 2026-08-31 02:42 | PASS | retro lesson 2026-06-10 (docker stats vs ps Size) | +| health-check-socket-degrade | A | 2026-08-31 02:42 | PASS | issue #762 (refs #756) — /health-check degrades to one statement, not nine failures | +| heartbeat-logging-contract | A | 2026-08-31 02:42 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | +| image-seed-hygiene | A | 2026-08-31 02:42 | PASS | issue #900 (slim the sandbox image) 2026-08-30 | +| markitdown-wiki-ingest | A | 2026-08-31 02:42 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | +| next-dev-prod | A | 2026-08-31 02:42 | SKIPPED | retro lesson 2026-06-04 | +| oh-compose-env-wiring | A | 2026-08-31 02:42 | PASS | issue #880 (oh as the only front door — oh.json is the non-secret config surface) | +| oh-config-surfaces | A | 2026-08-31 02:42 | REGRESSION | PR #887 (config split across two authored surfaces — a tracked oh.json and a secrets-only root dotenv — with nothing left under $HOME) | +| oh-destroy-guard | A | 2026-08-31 02:42 | PASS | issue #879 — `oh` becomes the only front door, so `make destroy` must | +| oh-devcontainer-restructure | A | 2026-08-31 02:42 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | +| oh-home-mount | A | 2026-08-31 02:42 | PASS | issue #898 (single $HOME mount) 2026-08-30 | +| oh-image-only-deploy | A | 2026-08-31 02:42 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | +| oh-init-headless-config | A | 2026-08-31 02:42 | PASS | PR #827 (installer answers landed in the losing config file); retargeted to the .example.env template by PR #833, then to oh.json by PR #887 | +| oh-init-scaffold | A | 2026-08-31 02:42 | PASS | issue #531 Phase 2 | +| oh-lifecycle-surface | A | 2026-08-31 02:42 | PASS | issue #881 — the Makefile is retired and `oh` is the only front door | +| oh-npm-package | A | 2026-08-31 02:42 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | +| oh-payload-manifest | A | 2026-08-31 02:42 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | +| oh-sandbox-image-mode | A | 2026-08-31 02:42 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | +| oh-shipped-repo-overridable | A | 2026-08-31 02:42 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | +| oh-standalone-lifecycle | A | 2026-08-31 02:42 | PASS | issue #564 | +| oh-update | A | 2026-08-31 02:42 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | +| operator-config-guard | A | 2026-08-31 02:42 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | +| pnpm-audit-ci-gate | A | 2026-08-31 02:42 | PASS | issue #171 — pnpm security audits must run in CI | +| post-bridge-publish-confirmation | A | 2026-08-31 02:42 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | +| prd-output-path-contract | A | 2026-08-31 02:42 | PASS | retro lesson 2026-06-19 | +| prompt-miner-schema-compat | A | 2026-08-31 02:42 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | +| prompt-miner-symlink-entrypoint | A | 2026-08-31 02:42 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | +| prompt-miner-weakness-record | A | 2026-08-31 02:42 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | +| protected-path-deletion | A | 2026-08-31 02:42 | PASS | .oh/tasks/spec-simplification/ (issue #816, US-001) — the critique gate was deleted, | +| protected-paths-resolve | A | 2026-08-31 02:42 | PASS | issue #753 — .claude/protected-paths.txt named 7 paths that did not exist. | +| registry-portability-gate | A | 2026-08-31 02:42 | PASS | issue #758 | +| registry-portability | A | 2026-08-31 02:42 | SKIPPED | issue #758 | +| retro-deterministic-contract | A | 2026-08-31 02:42 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | +| rl-delegation-write-worker | A | 2026-08-31 02:42 | PASS | retro lesson 2026-06-10 (rl-delegation) #57 | +| rlm-context-budget | A | 2026-08-31 02:42 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | +| runtime-preflight-gate | A | 2026-08-31 02:42 | PASS | issue #806 § B1 (open sandbox.substrate vs sandbox.runtime selector); | +| sandbox-boot-guard-ci | A | 2026-08-31 02:42 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19; | +| sandbox-node-base | A | 2026-08-31 02:42 | PASS | openharness#878 — oh as the only front door, T0 sandbox base image | +| skill-paths | A | 2026-08-31 02:42 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard; extended by issue #870 — deleted .oh/agents/advisor.md | +| skills-dir-clean | A | 2026-08-31 02:42 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | +| skills-task-tool-coupling | A | 2026-08-31 02:42 | PASS | council review 2026-08-29 (issue #886) — /delegate instructed Claude-Code-only | +| skills-vendored | A | 2026-08-31 02:42 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | +| slack-admin-command-surface | A | 2026-08-31 02:42 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | +| spec-family-contract | A | 2026-08-31 02:42 | PASS | issue #265; spec-simplification issue #816; workflow authority issue #854 | +| spec-ready-finalization | A | 2026-08-31 02:42 | PASS | issue #134; spec-simplification issue #816; workflow authority issue #854 | +| ste-checker-contract | A | 2026-08-31 02:42 | PASS | issue #750 PR audit — the /ste checker had four fail-open paths (unclosed | +| submitted-by-trailers | A | 2026-08-31 02:42 | PASS | conversation 2026-06-12 (commit attribution trailers); the single-owner | +| sync-skill-contract | A | 2026-08-31 02:42 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | +| tool-catalog-boundary | A | 2026-08-31 02:42 | PASS | agent-browser's exclusion from the harness catalog (#821) and the | +| version-parity | A | 2026-08-31 02:42 | PASS | conversation 2026-08-29 — the oh CLI became the only lifecycle door, so its | +| weigh-scorer-contract | A | 2026-08-31 02:42 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | +| wiki-readme-index | A | 2026-08-31 02:42 | PASS | issue #132 — wiki README index drift guard | +| workflow-boundaries | A | 2026-08-31 02:42 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259); authority moved to /spec in issue #854 | +| worktrees-layout | A | 2026-08-31 02:42 | PASS | issue #872 | diff --git a/.oh/evals/probes/harness-home-provisioning.sh b/.oh/evals/probes/default-provisioning.sh similarity index 52% rename from .oh/evals/probes/harness-home-provisioning.sh rename to .oh/evals/probes/default-provisioning.sh index 0799c9cd..7627c5d0 100755 --- a/.oh/evals/probes/harness-home-provisioning.sh +++ b/.oh/evals/probes/default-provisioning.sh @@ -4,19 +4,22 @@ # sudo has no NOPASSWD, so default harnesses install into the home mount # source: #904 — the image must not bake a default harness, or the boot-time # install path is dead code that CI and a normal boot both skip -# desc: every kind:"default" harness installs as the sandbox user into -# NPM_USER_PREFIX, claude-code keeps its postinstall, no default harness -# package appears in the Dockerfile, and the boot path carries the -# OH_PROVISION_HARNESSES guard and its provisioner. +# source: #906 — herdr and cloudflared are tools the in-sandbox CLI owns, and a +# root-installed default would hit `sudo` with no NOPASSWD +# desc: every kind:"default" harness AND tool installs as the sandbox user into +# NPM_USER_PREFIX, claude-code keeps its postinstall, no default package or +# download URL appears in the Dockerfile, and the boot path carries the +# OH_PROVISION_DEFAULTS guard and its provisioner. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" CATALOG="$ROOT/.oh/cli/src/lib/harnesses/catalog.ts" +TOOLS="$ROOT/.oh/cli/src/lib/tools/catalog.ts" ENTRY="$ROOT/.devcontainer/entrypoint.sh" DOCKERFILE="$ROOT/.devcontainer/Dockerfile" -PROVISIONER="$ROOT/.oh/scripts/provision-harnesses.sh" +PROVISIONER="$ROOT/.oh/scripts/provision-defaults.sh" -for f in "$CATALOG" "$ENTRY" "$DOCKERFILE"; do +for f in "$CATALOG" "$TOOLS" "$ENTRY" "$DOCKERFILE"; do if [[ ! -f $f ]]; then echo "SKIPPED: absent: $f" >&2 exit 2 @@ -58,14 +61,14 @@ if ((defaults == 0)); then exit 2 fi -grep -qF 'OH_PROVISION_HARNESSES' "$ENTRY" \ - || missing+=("entrypoint.sh: no OH_PROVISION_HARNESSES guard — nothing provisions harnesses into the home mount at boot") -grep -qF 'provision-harnesses.sh' "$ENTRY" \ - || missing+=("entrypoint.sh: does not call .oh/scripts/provision-harnesses.sh") -grep -qF 'WARNING: harness provisioning did not complete' "$ENTRY" \ - || missing+=("entrypoint.sh: harness provisioning does not warn-and-continue — an offline sandbox must still come up as a usable shell") +grep -qF 'OH_PROVISION_DEFAULTS' "$ENTRY" \ + || missing+=("entrypoint.sh: no OH_PROVISION_DEFAULTS guard — nothing provisions harnesses into the home mount at boot") +grep -qF 'provision-defaults.sh' "$ENTRY" \ + || missing+=("entrypoint.sh: does not call .oh/scripts/provision-defaults.sh") +grep -qF 'WARNING: default provisioning did not complete' "$ENTRY" \ + || missing+=("entrypoint.sh: default provisioning does not warn-and-continue — an offline sandbox must still come up as a usable shell") [[ -x $PROVISIONER ]] \ - || missing+=(".oh/scripts/provision-harnesses.sh: missing or not executable") + || missing+=(".oh/scripts/provision-defaults.sh: missing or not executable") # #904: the image must not bake any kind:"default" harness. The install target is # the home mount, so a copy under /usr/lib/node_modules shadows it with one no # running sandbox can upgrade — and, worse, makes the boot-time install path @@ -93,7 +96,7 @@ while IFS= read -r entry; do fi pkgs=$((pkgs + 1)) if grep -qF -- "$pkg" <<<"$DOCKERFILE_CODE"; then - missing+=("Dockerfile: names $pkg — default harness \"$id\" is baked into the image again; it belongs to .oh/scripts/provision-harnesses.sh, which installs it into $PREFIX at boot") + missing+=("Dockerfile: names $pkg — default harness \"$id\" is baked into the image again; it belongs to .oh/scripts/provision-defaults.sh, which installs it into $PREFIX at boot") fi done <<<"$entries" @@ -102,8 +105,47 @@ if ((pkgs == 0)); then exit 2 fi -if grep -qE '^ARG (BAKE_HARNESSES|AGENTS)=' <<<"$DOCKERFILE_CODE"; then - missing+=("Dockerfile: ARG BAKE_HARNESSES/AGENTS is back — a build-arg that re-bakes the default harnesses is a dormant path that reintroduces the shadowed install and un-exercises the boot provisioner") +if grep -qE '^ARG (BAKE_HARNESSES|AGENTS|HERDR_VERSION)=' <<<"$DOCKERFILE_CODE"; then + missing+=("Dockerfile: ARG BAKE_HARNESSES/AGENTS/HERDR_VERSION is back — a build arg that re-bakes a default is a dormant path that reintroduces the shadowed install and un-exercises the boot provisioner") +fi + +# #906: the same rule for kind:"default" tools. These install as root nowhere: +# commands/tool.ts passes stdio:"inherit", so local-target.ts selects plain +# `sudo --` for a root install, and /etc/sudoers.d/sandbox has no NOPASSWD — +# an agent in a Herdr pane would hang on a password prompt. +tool_entries=$(awk ' + /^ Object\.freeze\(\{$/ { buf=""; inb=1; next } + /^ \}\),$/ { if (inb) print buf; inb=0; next } + inb { buf = buf $0 " " } +' "$TOOLS") + +tools=0 +while IFS= read -r entry; do + [[ $entry == *'kind: "default"'* ]] || continue + tools=$((tools + 1)) + id=$(sed -n 's/.*id: "\([^"]*\)".*/\1/p' <<<"$entry") + if [[ $entry == *'installUser: "root"'* ]]; then + missing+=("tools/catalog.ts: default tool \"$id\" installs as root — commands/tool.ts uses stdio:\"inherit\", so that becomes an interactive \`sudo\`, and /etc/sudoers.d/sandbox has no NOPASSWD") + fi + if [[ $entry != *'NPM_USER_PREFIX'* ]]; then + missing+=("tools/catalog.ts: default tool \"$id\" does not install into NPM_USER_PREFIX — a system-path install cannot be upgraded by a running sandbox and does not persist in the home mount") + fi + if [[ $entry != *'sha256sum -c -'* ]]; then + missing+=("tools/catalog.ts: default tool \"$id\" downloads without \`sha256sum -c -\` — an unverified binary is installed straight into the agent's PATH") + fi + # Match the pinned project path, not the bare host — the Dockerfile clones + # oh-my-zsh plugins from github.com and that is not a baked tool. + while IFS= read -r origin; do + [[ -n $origin ]] || continue + if grep -qF -- "$origin" <<<"$DOCKERFILE_CODE"; then + missing+=("Dockerfile: names $origin — default tool \"$id\" is baked into the image again; it belongs to .oh/scripts/provision-defaults.sh, which installs it into $PREFIX at boot") + fi + done < <(grep -oE 'https://[a-z0-9.-]+/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+' <<<"$entry" | sort -u) +done <<<"$tool_entries" + +if ((tools == 0)); then + echo "SKIPPED: no kind:\"default\" tool parsed out of $TOOLS, so the tool half would pass vacuously" >&2 + exit 2 fi if ((${#missing[@]})); then @@ -111,4 +153,4 @@ if ((${#missing[@]})); then exit 1 fi -echo "PASS: all $defaults default harnesses install as the sandbox user into $PREFIX, none of the $pkgs packages is baked into the image, and the boot path provisions them" >&2 +echo "PASS: $defaults default harnesses and $tools default tools install as the sandbox user into $PREFIX, none of the $pkgs packages is baked into the image, and the boot path provisions them" >&2 diff --git a/.oh/evals/probes/sandbox-boot-guard-ci.sh b/.oh/evals/probes/sandbox-boot-guard-ci.sh index e603e14b..251d8001 100755 --- a/.oh/evals/probes/sandbox-boot-guard-ci.sh +++ b/.oh/evals/probes/sandbox-boot-guard-ci.sh @@ -76,8 +76,8 @@ fi # #904: boot-time harness provisioning is exercised nowhere else. Turning it off # here to save CI minutes would restore it to untested dead code. -if grep -Eq 'OH_PROVISION_HARNESSES: *"?false' <<<"$text"; then - missing+=("the boot guard disables OH_PROVISION_HARNESSES — this job is the only place the boot-time harness install runs") +if grep -Eq 'OH_PROVISION_DEFAULTS: *"?false' <<<"$text"; then + missing+=("the boot guard disables OH_PROVISION_DEFAULTS — this job is the only place the boot-time harness install runs") fi has 'Sandbox boot guard only' "comment explaining non-release intent" diff --git a/.oh/scripts/__tests__/herdr-default.test.ts b/.oh/scripts/__tests__/herdr-default.test.ts index 1b10150f..9ee4669d 100644 --- a/.oh/scripts/__tests__/herdr-default.test.ts +++ b/.oh/scripts/__tests__/herdr-default.test.ts @@ -7,14 +7,23 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ const readRepoFile = (file: string): string => readFileSync(path.join(repoRoot, file), "utf8"); describe("default Herdr integration", () => { + // #906: the pin moved out of the Dockerfile and into the tool catalog, which + // provisions Herdr into the home mount at boot. The image no longer carries it. it("pins and verifies Herdr for both supported architectures", () => { + const catalog = readRepoFile(".oh/cli/src/lib/tools/catalog.ts"); + + expect(catalog).toContain("version=0.7.4"); + expect(catalog).toContain("bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059"); + expect(catalog).toContain("544e0002de42806d1ab64ccdef3a7e7414f24717b0b6b022bc9e57d2eefd26a2"); + expect(catalog).toContain("sha256sum -c -"); + expect(catalog).toContain('test "$("$prefix/bin/herdr" --version)" = "herdr $version"'); + }); + + it("no longer bakes Herdr into the image", () => { const dockerfile = readRepoFile(".devcontainer/Dockerfile"); - expect(dockerfile).toContain("HERDR_VERSION=0.7.4"); - expect(dockerfile).toContain("bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059"); - expect(dockerfile).toContain("544e0002de42806d1ab64ccdef3a7e7414f24717b0b6b022bc9e57d2eefd26a2"); - expect(dockerfile).toContain("sha256sum -c -"); - expect(dockerfile).toContain('test "$(herdr --version)" = "herdr ${HERDR_VERSION}"'); + expect(dockerfile).not.toContain("HERDR_VERSION"); + expect(dockerfile).not.toContain("github.com/ogulcancelik/herdr"); }); it.each(["docker-compose.yml", "docker-compose.image-only.yml"])( diff --git a/.oh/scripts/__tests__/sandbox-base-image.test.ts b/.oh/scripts/__tests__/sandbox-base-image.test.ts index c73ca3cd..b50b5041 100644 --- a/.oh/scripts/__tests__/sandbox-base-image.test.ts +++ b/.oh/scripts/__tests__/sandbox-base-image.test.ts @@ -34,29 +34,19 @@ describe("sandbox base image", () => { expect(dockerfile).not.toContain("https://download.docker.com/linux/debian bookworm stable"); }); - it("keeps Cloudflare's apt repository on Bookworm", () => { - expect(dockerfile).toContain("https://pkg.cloudflare.com/cloudflared bookworm main"); - expect(dockerfile).not.toContain("https://pkg.cloudflare.com/cloudflared trixie main"); + // #906: cloudflared moved out of the image into the tool catalog, which + // installs a pinned, checksum-verified binary. That deleted the last reason + // this Dockerfile referenced a non-Trixie apt suite. + it("no longer carries Cloudflare's apt repository", () => { + expect(dockerfile).not.toContain("pkg.cloudflare.com"); }); - it("explains the Cloudflare Bookworm exception next to that repository", () => { - const lines = dockerfile.split("\n"); - const suiteLine = lines.findIndex((line) => line.includes("pkg.cloudflare.com/cloudflared bookworm main")); - expect(suiteLine).toBeGreaterThan(-1); - - const preamble = lines.slice(Math.max(0, suiteLine - 8), suiteLine).join("\n"); - expect(preamble).toMatch(/^#.*trixie/mi); - expect(preamble).toContain("404"); - }); - - it("leaves every other suite reference off Bookworm", () => { + it("leaves no suite reference on Bookworm at all", () => { const bookwormLines = dockerfile .split("\n") .filter((line) => /bookworm/i.test(line)) .filter((line) => !line.trimStart().startsWith("#")); - expect(bookwormLines).toEqual([ - expect.stringContaining("https://pkg.cloudflare.com/cloudflared bookworm main"), - ]); + expect(bookwormLines).toEqual([]); }); }); diff --git a/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts b/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts index 9eceef08..f5d8ac72 100644 --- a/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts +++ b/.oh/scripts/__tests__/sandbox-boot-smoke.test.ts @@ -18,6 +18,7 @@ function fixture( markerOwner?: string; harnessProbeFails?: boolean; noDefaultHarnesses?: boolean; + noDefaultTools?: boolean; } = {}, ) { const runtimeUid = opts.runtimeUid ?? HOST_UID; @@ -86,6 +87,19 @@ ${ { "id": "pi", "title": "Pi", "binary": "pi", "kind": "default", "enabled": null, "installed": true, "docs": "x" } ]` } +JSON + exit 0 + ;; + *"oh tool list --defaults --json"*) + cat <<'JSON' +${ + opts.noDefaultTools + ? "[]" + : `[ + { "id": "herdr", "title": "Herdr", "binary": "herdr", "kind": "default", "enabled": null, "installed": true, "docs": "x" }, + { "id": "cloudflared", "title": "cloudflared", "binary": "cloudflared", "kind": "default", "enabled": null, "installed": true, "docs": "x" } +]` +} JSON exit 0 ;; @@ -166,9 +180,12 @@ describe("sandbox boot smoke", () => { `sandbox user, bind mount, and sandbox-created files all resolve to ${HOST_UID}:${HOST_GID}`, ); expect(dockerCalls).toContain("oh harness list --defaults --json"); + expect(dockerCalls).toContain("oh tool list --defaults --json"); expect(dockerCalls).toContain("type -P"); expect(result.stdout).toContain("claude-code provisioned at boot -> 1.2.3"); expect(result.stdout).toContain("pi provisioned at boot -> 1.2.3"); + expect(result.stdout).toContain("herdr provisioned at boot -> 1.2.3"); + expect(result.stdout).toContain("cloudflared provisioned at boot -> 1.2.3"); }); // #904 deleted the image bake, so this assertion is the only thing standing @@ -186,13 +203,16 @@ describe("sandbox boot smoke", () => { expect(readFileSync(fx.composeLog, "utf8")).toContain("down -v --remove-orphans"); }); - it("refuses to pass vacuously when the catalog reports no default harnesses", () => { - const fx = fixture({ noDefaultHarnesses: true }); + it.each<[string, { noDefaultHarnesses?: boolean; noDefaultTools?: boolean }]>([ + ["harness", { noDefaultHarnesses: true }], + ["tool", { noDefaultTools: true }], + ])("refuses to pass vacuously when the %s catalog reports no defaults", (noun, overrides) => { + const fx = fixture(overrides); const result = runSmoke(fx); expect(result.status).toBe(1); - expect(result.stderr).toContain('reported no kind:"default" harnesses'); + expect(result.stderr).toContain(`the ${noun} catalog reported no kind:"default" entries`); }); it("fails when the runtime sandbox user does not match the checkout owner", () => { diff --git a/.oh/scripts/__tests__/sandbox-healthcheck.test.ts b/.oh/scripts/__tests__/sandbox-healthcheck.test.ts index 27b36ff2..d68b167f 100644 --- a/.oh/scripts/__tests__/sandbox-healthcheck.test.ts +++ b/.oh/scripts/__tests__/sandbox-healthcheck.test.ts @@ -137,7 +137,7 @@ describe("sandbox healthcheck", () => { expect(compose).toContain("healthcheck:"); expect(compose).toContain("/home/sandbox/harness/.oh/scripts/sandbox-healthcheck.sh"); // Boot installs the default harnesses into the home mount (#904), bounded - // by OH_PROVISION_HARNESSES_TIMEOUT (180s). The start period has to cover + // by OH_PROVISION_DEFAULTS_TIMEOUT (240s). The start period has to cover // that plus the rest of boot, so assert the floor rather than a literal // that a reduction could slip past. const startPeriod = /start_period: (\d+)s/.exec(compose); diff --git a/.oh/scripts/__tests__/verify-sandbox-image.test.ts b/.oh/scripts/__tests__/verify-sandbox-image.test.ts index 0ce05a8d..17dafbc2 100644 --- a/.oh/scripts/__tests__/verify-sandbox-image.test.ts +++ b/.oh/scripts/__tests__/verify-sandbox-image.test.ts @@ -7,24 +7,21 @@ import { describe, expect, it } from "vitest"; const ROOT = join(import.meta.dirname, "../../.."); const SCRIPT = join(ROOT, ".oh", "scripts", "verify-sandbox-image.sh"); -const AMD64_SHA = "bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059"; - type Overrides = Partial<{ architecture: string; codename: string; dockerSuite: string; - cloudflareSuite: string; uid: string; gid: string; node: string; pnpm: string; - herdr: string; - herdrSha: string; missingTool: string; nonVersionTool: string; platformWarning: string; bakedHarnesses: boolean; + bakedTools: boolean; noDefaultHarnesses: boolean; + noDefaultTools: boolean; harnessCatalogFails: boolean; }>; @@ -37,18 +34,17 @@ function fixture(o: Overrides = {}) { architecture: "amd64", codename: "trixie", dockerSuite: "trixie", - cloudflareSuite: "bookworm", uid: "1000", gid: "1000", node: "v22.14.0", pnpm: "10.33.0", - herdr: "herdr 0.7.4", - herdrSha: AMD64_SHA, missingTool: "", nonVersionTool: "", platformWarning: "", bakedHarnesses: false, + bakedTools: false, noDefaultHarnesses: false, + noDefaultTools: false, harnessCatalogFails: false, ...o, }; @@ -62,12 +58,9 @@ cmd="\${@: -1}" case "$cmd" in *VERSION_CODENAME*) printf '%s' ${JSON.stringify(v.codename)} ;; *docker.list*) printf 'deb [arch=amd64] https://download.docker.com/linux/debian %s stable\\n' ${JSON.stringify(v.dockerSuite)} ;; - *cloudflared.list*) printf 'deb [arch=amd64] https://pkg.cloudflare.com/cloudflared %s main\\n' ${JSON.stringify(v.cloudflareSuite)} ;; *"id -u sandbox"*) printf '%s\\n%s\\n' ${JSON.stringify(v.uid)} ${JSON.stringify(v.gid)} ;; "node --version") printf '%s\\n' ${JSON.stringify(v.node)} ;; "pnpm --version") printf '%s\\n' ${JSON.stringify(v.pnpm)} ;; - "herdr --version") printf '%s\\n' ${JSON.stringify(v.herdr)} ;; - *sha256sum*) printf '%s /usr/local/bin/herdr\\n' ${JSON.stringify(v.herdrSha)} ;; *"oh harness list --defaults --json"*) if [ "${v.harnessCatalogFails ? "1" : "0"}" = "1" ]; then echo 'not an OpenHarness-equipped repo' >&2 @@ -82,6 +75,18 @@ ${ { "id": "pi", "binary": "pi", "kind": "default", "installed": false } ]` } +JSON + ;; + *"oh tool list --defaults --json"*) + cat <<'JSON' +${ + v.noDefaultTools + ? "[]" + : `[ + { "id": "herdr", "binary": "herdr", "kind": "default", "installed": ${v.bakedTools} }, + { "id": "cloudflared", "binary": "cloudflared", "kind": "default", "installed": false } +]` +} JSON ;; *) @@ -122,12 +127,11 @@ describe("verify-sandbox-image", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("base distribution is Debian trixie"); expect(result.stdout).toContain("Docker apt suite is trixie"); - expect(result.stdout).toContain("Cloudflare apt suite is bookworm"); expect(result.stdout).toContain("built-in sandbox user is 1000:1000"); expect(result.stdout).toContain("node is major 22"); expect(result.stdout).toContain("pnpm is exactly 10.33.0"); - expect(result.stdout).toContain("herdr is 0.7.4"); - expect(result.stdout).toContain("matches the amd64 (x86_64) Dockerfile checksum pin"); + expect(result.stdout).toContain("no default harness is baked into the image"); + expect(result.stdout).toContain("no default tool is baked into the image"); expect(result.stdout).toContain("all checks passed"); }); @@ -141,12 +145,9 @@ describe("verify-sandbox-image", () => { it.each<[string, Overrides, string]>([ ["a Bookworm base", { codename: "bookworm" }, "base distribution codename is 'bookworm'"], ["a Bookworm Docker suite", { dockerSuite: "bookworm" }, "Docker apt suite is not trixie"], - ["a Trixie Cloudflare suite", { cloudflareSuite: "trixie" }, "Cloudflare apt suite is not bookworm"], ["a shifted sandbox UID", { uid: "1001" }, "built-in sandbox user is 1001:1000"], ["a wrong Node major", { node: "v20.19.0" }, "node major is not 22"], ["a drifted pnpm version", { pnpm: "10.34.0" }, "pnpm is 10.34.0"], - ["a drifted Herdr version", { herdr: "herdr 0.7.3" }, "herdr is 'herdr 0.7.3'"], - ["a Herdr binary that misses its pin", { herdrSha: "deadbeef" }, "does not match the amd64 pin"], ["a missing required tool", { missingTool: "uv --version" }, "uv --version produced no version output"], ])("rejects %s", (_label, overrides, expected) => { const result = run(fixture(overrides)); @@ -159,7 +160,6 @@ describe("verify-sandbox-image", () => { "gh --version", "docker --version", "docker compose version", - "cloudflared --version", "bun --version", "uv --version", ])("rejects clean but non-version output from %s", (tool) => { @@ -186,37 +186,43 @@ describe("verify-sandbox-image", () => { expect(result.stderr).toContain("unsupported image architecture: riscv64"); }); - it("resolves the arm64 checksum pin from the Dockerfile", () => { - const result = run( - fixture({ - architecture: "arm64", - herdrSha: "544e0002de42806d1ab64ccdef3a7e7414f24717b0b6b022bc9e57d2eefd26a2", - }), - ); + it("accepts an arm64 image", () => { + const result = run(fixture({ architecture: "arm64" })); expect(result.status).toBe(0); - expect(result.stdout).toContain("matches the arm64 (aarch64) Dockerfile checksum pin"); }); - // #904: the default harnesses moved out of the image and into the boot path. - // A baked copy under /usr/lib/node_modules shadows the home-mount install and - // silently un-exercises the provisioner, so the image must not carry one. - it("passes an image that bakes no default harness", () => { + // #904/#906: the default harnesses AND the default tools moved out of the + // image and into the boot path. A baked copy in a system path shadows the + // home-mount install and silently un-exercises the provisioner, so the image + // must not carry either. + it("passes an image that bakes no default harness or tool", () => { const result = run(fixture()); expect(result.status).toBe(0); expect(result.stdout).toContain("no default harness is baked into the image"); + expect(result.stdout).toContain("no default tool is baked into the image"); }); it("rejects an image that bakes a default harness", () => { const result = run(fixture({ bakedHarnesses: true })); expect(result.status).toBe(1); - expect(result.stderr).toContain("the image ships baked default harnesses: claude-code (claude)"); + expect(result.stderr).toContain("the image ships baked default harnesss: claude-code (claude)"); + }); + + it("rejects an image that bakes a default tool", () => { + const result = run(fixture({ bakedTools: true })); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("the image ships baked default tools: herdr (herdr)"); }); - it("refuses to pass vacuously when the image catalog lists no default harness", () => { - const result = run(fixture({ noDefaultHarnesses: true })); + it.each<[string, Overrides]>([ + ["harness", { noDefaultHarnesses: true }], + ["tool", { noDefaultTools: true }], + ])("refuses to pass vacuously when the image lists no default %s", (_noun, overrides) => { + const result = run(fixture(overrides)); expect(result.status).toBe(1); expect(result.stderr).toContain("would pass vacuously"); diff --git a/.oh/scripts/provision-harnesses.sh b/.oh/scripts/provision-defaults.sh similarity index 59% rename from .oh/scripts/provision-harnesses.sh rename to .oh/scripts/provision-defaults.sh index b1f9f2cf..bb029361 100755 --- a/.oh/scripts/provision-harnesses.sh +++ b/.oh/scripts/provision-defaults.sh @@ -12,12 +12,12 @@ case "${1:-}" in *) echo "usage: $(basename "$0") [--verify]" >&2; exit 2 ;; esac -log() { echo "[provision-harnesses] $*"; } +log() { echo "[provision-defaults] $*"; } die() { - echo "[provision-harnesses] ERROR: $1" >&2 + echo "[provision-defaults] ERROR: $1" >&2 shift - for line in "$@"; do echo "[provision-harnesses] $line" >&2; done + for line in "$@"; do echo "[provision-defaults] $line" >&2; done exit 1 } @@ -33,7 +33,7 @@ inside_sandbox || die \ "this provisions /home/$SANDBOX_USER/.local inside the sandbox and must not run on the host" \ "open a sandbox shell first:" \ " oh shell" \ - " bash .oh/scripts/provision-harnesses.sh" + " bash .oh/scripts/provision-defaults.sh" export OH_EXECUTION_TARGET=local @@ -103,56 +103,76 @@ command -v jq >/dev/null 2>&1 || die \ "the image installs it with apt; rebuild the sandbox image:" \ " oh sandbox" -STATES="" -if ! STATES="$("$OH_BIN" harness list --defaults --json 2>/dev/null)" || [ -z "$STATES" ]; then - die "'$OH_BIN harness list --defaults --json' produced no catalog" \ - "the CLI at $(command -v "$OH_BIN") predates \`oh harness\`; the harness catalog" \ - "is the only source of truth for what to install, so there is nothing to provision." \ - "rebuild the sandbox image from this control plane:" \ - " oh sandbox" -fi - -DEFAULTS="$(jq -r '.[] | select(.kind == "default") | "\(.id)\t\(.installed)"' <<<"$STATES")" -[ -n "$DEFAULTS" ] || die \ - "the harness catalog declares no default harnesses" \ - "check .oh/cli/src/lib/harnesses/catalog.ts" - +# Both catalogs answer the same question — "what does a working sandbox need that +# is not in the image?" — and get the same policy: install what is missing, never +# replace what is already there. The catalogs are the only source of truth for +# the list, so this script never names a package. missing=() failed=() +provisioned=0 -while IFS=$'\t' read -r id installed; do - [ -n "$id" ] || continue - if [ "$installed" = "true" ]; then - log "OK $id present (unpinned — an existing install is never replaced)" - continue - fi - if [ "$MODE" = "verify" ]; then - missing+=("$id") - continue - fi - log "installing $id into $NPM_USER_PREFIX" - if "$OH_BIN" harness install "$id" --no-persist /dev/null)" || [ -z "$states" ]; then + die "'$OH_BIN $cmd list --defaults --json' produced no catalog" \ + "the CLI at $(command -v "$OH_BIN") predates \`oh $cmd --defaults\`; the catalog" \ + "is the only source of truth for what to install, so there is nothing to provision." \ + "rebuild the sandbox image from this control plane:" \ + " oh sandbox" fi -done <<<"$DEFAULTS" + + defaults="$(jq -r '.[] | select(.kind == "default") | "\(.id)\t\(.installed)"' <<<"$states")" + [ -n "$defaults" ] || die \ + "the $noun catalog declares no defaults" \ + "check $catalog" + + while IFS=$'\t' read -r id installed; do + [ -n "$id" ] || continue + provisioned=$((provisioned + 1)) + if [ "$installed" = "true" ]; then + log "OK $id present (unpinned — an existing install is never replaced)" + continue + fi + if [ "$MODE" = "verify" ]; then + missing+=("$id") + continue + fi + log "installing $id into $NPM_USER_PREFIX" + if "$OH_BIN" "$cmd" install "$id" --no-persist /tmp/sandbox-boot-smoke-harness.err); then - echo "sandbox boot smoke failed: 'oh harness list --defaults --json' did not run in the booted sandbox" >&2 - cat /tmp/sandbox-boot-smoke-harness.err >&2 || true + if ! states=$(docker exec -u sandbox "$cid" bash -lc "oh $cmd list --defaults --json" 2>/tmp/sandbox-boot-smoke-catalog.err); then + echo "sandbox boot smoke failed: 'oh $cmd list --defaults --json' did not run in the booted sandbox" >&2 + cat /tmp/sandbox-boot-smoke-catalog.err >&2 || true return 1 fi ids=$(jq -r '.[] | select(.kind == "default") | .id' <<<"$states") if [ -z "$ids" ]; then - echo "sandbox boot smoke failed: the harness catalog reported no kind:\"default\" harnesses, so this check would pass vacuously" >&2 + echo "sandbox boot smoke failed: the $noun catalog reported no kind:\"default\" entries, so this check would pass vacuously" >&2 return 1 fi @@ -121,7 +122,7 @@ verify_default_harnesses() { [ -n "$id" ] || continue binary=$(jq -r --arg id "$id" '.[] | select(.id == $id) | .binary' <<<"$states") if [ -z "$binary" ] || [ "$binary" = "null" ]; then - echo "sandbox boot smoke failed: default harness '$id' declares no binary to check" >&2 + echo "sandbox boot smoke failed: default $noun '$id' declares no binary to check" >&2 failed=1 continue fi @@ -136,7 +137,7 @@ verify_default_harnesses() { [ \"\$owner\" = '$sandbox_uid' ] || { echo \"binary is owned by uid \$owner, not sandbox ($sandbox_uid)\" >&2; exit 1; } \"\$path\" --version " 2>&1); then - echo "sandbox boot smoke failed: default harness '$id' was not provisioned into the home mount at boot" >&2 + echo "sandbox boot smoke failed: default $noun '$id' was not provisioned into the home mount at boot" >&2 printf ' %s\n' "$out" >&2 failed=1 continue @@ -169,7 +170,7 @@ while [ "$(date +%s)" -le "$end" ]; do # shellcheck disable=SC2086 # HEALTH_CMD intentionally splits into command argv. if docker exec "$cid" $HEALTH_CMD >/tmp/sandbox-boot-smoke-health.out 2>/tmp/sandbox-boot-smoke-health.err; then if ! docker exec -u sandbox "$cid" sh -lc \ - 'test "$(herdr --version)" = "herdr 0.7.4" && test -w "$HOME/.config" && test -w "$HOME/.herdr" && command -v lsof >/dev/null && lsof -v >/dev/null 2>&1 && command -v htop >/dev/null && htop --version >/dev/null && command -v telnet >/dev/null && telnet --version >/dev/null'; then + 'test -w "$HOME/.config" && test -w "$HOME/.herdr" && command -v lsof >/dev/null && lsof -v >/dev/null 2>&1 && command -v htop >/dev/null && htop --version >/dev/null && command -v telnet >/dev/null && telnet --version >/dev/null'; then echo "sandbox boot smoke failed: required utilities, Herdr runtime, or writable state is unavailable" >&2 status_diagnostics "$cid" exit 1 @@ -178,11 +179,15 @@ while [ "$(date +%s)" -le "$end" ]; do status_diagnostics "$cid" exit 1 fi - if ! verify_default_harnesses "$cid"; then + if ! verify_default_catalog "$cid" harness harness; then + status_diagnostics "$cid" + exit 1 + fi + if ! verify_default_catalog "$cid" tool tool; then status_diagnostics "$cid" exit 1 fi - echo "sandbox boot smoke ok: $SERVICE ($cid) passed $HEALTH_CMD, Herdr runtime, bind-ownership, and boot-provisioned harness checks" + echo "sandbox boot smoke ok: $SERVICE ($cid) passed $HEALTH_CMD, Herdr runtime, bind-ownership, and boot-provisioned harness and tool checks" exit 0 fi last_status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}' "$cid" 2>/dev/null || echo "inspect-failed") diff --git a/.oh/scripts/verify-sandbox-image.sh b/.oh/scripts/verify-sandbox-image.sh index 18342b2a..c3ff9996 100755 --- a/.oh/scripts/verify-sandbox-image.sh +++ b/.oh/scripts/verify-sandbox-image.sh @@ -1,23 +1,17 @@ #!/usr/bin/env bash # Verify a built sandbox image: base distribution, apt suites, the sandbox -# UID/GID contract, the Node/pnpm pins, the Herdr checksum, and version output -# from every required default tool, and that no kind:"default" harness is baked -# into it. Usage: verify-sandbox-image.sh +# UID/GID contract, the Node/pnpm pins, and version output from every baked-in +# tool, and that no kind:"default" harness or tool is baked into it. +# Usage: verify-sandbox-image.sh set -euo pipefail -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) -DOCKERFILE=${VERIFY_IMAGE_DOCKERFILE:-$REPO_ROOT/.devcontainer/Dockerfile} - EXPECTED_CODENAME=trixie EXPECTED_DOCKER_SUITE=trixie -EXPECTED_CLOUDFLARE_SUITE=bookworm EXPECTED_UID=1000 EXPECTED_GID=1000 EXPECTED_NODE_MAJOR=22 EXPECTED_PNPM=10.33.0 -EXPECTED_HERDR=0.7.4 usage() { echo "usage: ${0##*/} " >&2 @@ -39,20 +33,10 @@ arch=$(docker image inspect -f '{{.Architecture}}' "$IMAGE") echo "verifying $IMAGE (architecture: $arch)" case "$arch" in - amd64) herdr_arch=x86_64 ;; - arm64) herdr_arch=aarch64 ;; + amd64|arm64) ;; *) echo "FAIL: unsupported image architecture: $arch" >&2; exit 1 ;; esac -expected_sha=$( - awk -v a="$arch" '$0 ~ a"\\)" && /herdr_sha=/ { - for (i = 1; i <= NF; i++) if ($i ~ /^herdr_sha=/) { sub(/^herdr_sha=/, "", $i); print $i; exit } - }' "$DOCKERFILE" -) -if [ -z "$expected_sha" ]; then - fail "no herdr_sha pinned for $arch in ${DOCKERFILE#"$REPO_ROOT"/}" -fi - codename=$(run '. /etc/os-release && printf "%s" "${VERSION_CODENAME:-}"') if [ "$codename" = "$EXPECTED_CODENAME" ]; then ok "base distribution is Debian $EXPECTED_CODENAME" @@ -67,13 +51,6 @@ else fail "Docker apt suite is not $EXPECTED_DOCKER_SUITE: $docker_suite" fi -cf_suite=$(run 'cat /etc/apt/sources.list.d/cloudflared.list') -if grep -qF "cloudflared $EXPECTED_CLOUDFLARE_SUITE main" <<<"$cf_suite"; then - ok "Cloudflare apt suite is $EXPECTED_CLOUDFLARE_SUITE (no Trixie suite is published)" -else - fail "Cloudflare apt suite is not $EXPECTED_CLOUDFLARE_SUITE: $cf_suite" -fi - ids=$(run 'id -u sandbox; id -g sandbox') built_uid=$(sed -n 1p <<<"$ids") built_gid=$(sed -n 2p <<<"$ids") @@ -97,22 +74,6 @@ else fail "pnpm is $pnpm_version, expected exactly $EXPECTED_PNPM" fi -herdr_version=$(run 'herdr --version') -if [ "$herdr_version" = "herdr $EXPECTED_HERDR" ]; then - ok "herdr is $EXPECTED_HERDR" -else - fail "herdr is '$herdr_version', expected 'herdr $EXPECTED_HERDR'" -fi - -if [ -n "$expected_sha" ]; then - actual_sha=$(run 'sha256sum /usr/local/bin/herdr' | awk '{print $1}') - if [ "$actual_sha" = "$expected_sha" ]; then - ok "installed herdr matches the $arch ($herdr_arch) Dockerfile checksum pin" - else - fail "installed herdr checksum $actual_sha does not match the $arch pin $expected_sha" - fi -fi - # Under emulation `docker run` prefixes its output with a platform-mismatch # warning on stderr. Drop it so the reported line is the tool's own version, # not the runner's complaint about the architecture. @@ -125,7 +86,7 @@ has_numeric_dotted_version() { } for tool in "gh --version" "docker --version" "docker compose version" \ - "cloudflared --version" "bun --version" "uv --version"; do + "bun --version" "uv --version"; do if out=$(run "$tool" 2>&1); then line=$(first_real_line <<<"$out") if has_numeric_dotted_version <<<"$line"; then @@ -138,30 +99,39 @@ for tool in "gh --version" "docker --version" "docker compose version" \ fi done -# The image must NOT ship the default harnesses (#904). They are the in-sandbox -# CLI's responsibility and are installed into the home mount at boot, so a -# default harness found here means the bake came back and the home mount's copy -# is shadowed by an unupgradable one under /usr/lib/node_modules. The catalog in -# the image is the source of truth for which ids are default, so this cannot -# drift from harnesses/catalog.ts. -if defaults_json=$(run 'cd /opt/oh-seed && OH_EXECUTION_TARGET=local oh harness list --defaults --json' 2>/tmp/verify-sandbox-defaults.err); then - if command -v jq >/dev/null 2>&1; then - default_ids=$(jq -r '.[] | select(.kind == "default") | .id' <<<"$defaults_json") - if [ -z "$default_ids" ]; then - fail "the image's harness catalog reports no kind:\"default\" harnesses — the unbaked-image check would pass vacuously" - else - baked=$(jq -r '.[] | select(.kind == "default" and .installed == true) | "\(.id) (\(.binary))"' <<<"$defaults_json") - if [ -n "$baked" ]; then - fail "the image ships baked default harnesses: $(tr '\n' ' ' <<<"$baked")— these must be provisioned into /home/sandbox/.local at boot, not baked" - else - ok "no default harness is baked into the image ($(tr '\n' ' ' <<<"$default_ids"))" - fi - fi +# The image must NOT ship any kind:"default" harness (#904) or tool (#906). +# Both are installed into /home/sandbox/.local at boot: a copy baked into a +# system path shadows the home-mount install with one no running sandbox can +# upgrade, and makes the boot install dead code that never runs and never gets +# tested. The catalogs inside the image are the source of truth for which ids +# are default, so this cannot drift from the TypeScript. +check_no_baked_defaults() { + local noun="$1" cmd="$2" json ids baked + + if ! json=$(run "cd /opt/oh-seed && OH_EXECUTION_TARGET=local oh $cmd list --defaults --json" 2>/tmp/verify-sandbox-defaults.err); then + fail "could not read the $noun catalog from the image: $(head -3 /tmp/verify-sandbox-defaults.err 2>/dev/null)" + return + fi + + ids=$(jq -r '.[] | select(.kind == "default") | .id' <<<"$json") + if [ -z "$ids" ]; then + fail "the image's $noun catalog reports no kind:\"default\" entries — the unbaked-image check would pass vacuously" + return + fi + + baked=$(jq -r '.[] | select(.kind == "default" and .installed == true) | "\(.id) (\(.binary))"' <<<"$json") + if [ -n "$baked" ]; then + fail "the image ships baked default ${noun}s: $(tr '\n' ' ' <<<"$baked")— these must be provisioned into /home/sandbox/.local at boot, not baked" else - fail "jq is required to read the image's harness catalog JSON" + ok "no default $noun is baked into the image ($(tr '\n' ' ' <<<"$ids"))" fi +} + +if command -v jq >/dev/null 2>&1; then + check_no_baked_defaults harness harness + check_no_baked_defaults tool tool else - fail "could not read the harness catalog from the image: $(cat /tmp/verify-sandbox-defaults.err 2>/dev/null | head -3)" + fail "jq is required to read the image's harness and tool catalogs" fi if ((${#failures[@]})); then diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2b6452..1394c495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,15 +12,18 @@ Update policy and release automation live in [`/git`](.claude/skills/git/SKILL.m - **BREAKING:** Persist the sandbox home through one `/home/sandbox` mount, not eleven per-tool volumes; set `storage.homePath` for a host path, else `_workspace` ([#898](https://github.com/mifunedev/openharness/issues/898)). - Shrink the sandbox image ~540 MB: drop build caches from the baked home seed, stage the seed once via a builder stage, and keep untracked build output out of the build context ([#900](https://github.com/mifunedev/openharness/issues/900)). - **BREAKING:** Stop baking Claude Code, Codex, and Pi into the image; boot installs them into the home mount, so a first boot needs network and runs 60-180s longer ([#904](https://github.com/mifunedev/openharness/issues/904)). +- **BREAKING:** Stop baking Herdr and cloudflared into the image; both become `kind: "default"` tools installed into `~/.local/bin` at boot from a pinned, checksum-verified binary ([#906](https://github.com/mifunedev/openharness/issues/906)). ### Removed - Remove the `BAKE_HARNESSES` and `AGENTS` build args along with the image bake they gated; the harness catalog is the only source of truth for what gets installed ([#904](https://github.com/mifunedev/openharness/issues/904)). +- Remove Cloudflare's apt repository and its bookworm-suite pin from the image; Docker's is now the only third-party apt source ([#906](https://github.com/mifunedev/openharness/issues/906)). - **BREAKING:** Retire the `projectRoot` / `OH_PROJECT_ROOT` config knob — the checkout is fixed at `/home/sandbox/harness`, nested inside the home mount ([#898](https://github.com/mifunedev/openharness/issues/898)). ### Added - Provision the default harnesses into `/home/sandbox/.local` at boot, gated by `OH_PROVISION_HARNESSES`, so `oh harness install` also works from inside the sandbox ([#902](https://github.com/mifunedev/openharness/issues/902)). - Add `oh-home-mount.sh`, a tier-A probe holding the single-`$HOME`-mount contract: one mount per compose file, the baked `/opt/home-seed`, and the checkout prune that replaces `-xdev` ([#898](https://github.com/mifunedev/openharness/issues/898)). - Assert boot-provisioned harnesses in the boot smoke and reject a baked default harness in `verify-sandbox-image.sh`, so CI exercises the install path ([#904](https://github.com/mifunedev/openharness/issues/904)). +- Add `oh tool list --defaults` and generalize the boot provisioner over both catalogs as `provision-defaults.sh` (`OH_PROVISION_DEFAULTS`) ([#906](https://github.com/mifunedev/openharness/issues/906)). - Add `skills-task-tool-coupling.sh`, a tier-A probe holding the canonical skill pack and the sandbox in agreement about the Claude-Code-only task tools ([#886](https://github.com/mifunedev/openharness/issues/886)). ### Fixed diff --git a/docs/deployment-prebuilt-image.md b/docs/deployment-prebuilt-image.md index b934d978..6c2d4378 100644 --- a/docs/deployment-prebuilt-image.md +++ b/docs/deployment-prebuilt-image.md @@ -239,7 +239,7 @@ into `/home/sandbox/.local`; they are not baked into the image. Expect the boot to run 60–180s longer than the `sleep 8` above and to need network — check with `docker exec "$NAME" bash -lc 'oh harness list --defaults'`. If the registry was unreachable the container still comes up; re-run -`docker exec "$NAME" bash -lc 'bash /home/sandbox/harness/.oh/scripts/provision-harnesses.sh'`. +`docker exec "$NAME" bash -lc 'bash /home/sandbox/harness/.oh/scripts/provision-defaults.sh'`. ```bash # ── 4. Attach an interactive shell (once the container is stable) ── diff --git a/docs/installation.md b/docs/installation.md index 2223e93a..c9b45a9f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -276,28 +276,32 @@ Project-local Pi packages are loaded from `.pi/settings.json`; the defaults incl Debian Trixie (slim), the current Debian stable. The `sandbox` user has passwordless sudo. -Docker's apt repository tracks the `trixie` suite. Cloudflare's stays on `bookworm`: Cloudflare publishes no Trixie suite (`pkg.cloudflare.com/cloudflared/dists/trixie` returns HTTP 404) and its Bookworm `cloudflared` package runs on Trixie. +Docker's apt repository tracks the `trixie` suite, and it is now the only third-party apt source in the image. cloudflared used to force a `bookworm` suite here because Cloudflare publishes no Trixie suite (`pkg.cloudflare.com/cloudflared/dists/trixie` returns HTTP 404); moving it to a pinned, checksum-verified binary in the tool catalog removed that exception. ### AI agent CLIs Default CLIs are not baked into the image. The entrypoint runs -`.oh/scripts/provision-harnesses.sh` on every boot, which installs any missing -default harness into `~/.local` — inside the home mount — as the `sandbox` user. -That is what makes `oh harness install ` able to upgrade one in place: a copy -under `/usr/lib/node_modules` would be root-owned and unwritable from a running -sandbox. Consequences worth knowing: +`.oh/scripts/provision-defaults.sh` on every boot, which installs any missing +default **harness** (Claude Code, Codex, Pi) and default **tool** (Herdr, +cloudflared) into `~/.local` — inside the home mount — as the `sandbox` user. +That is what makes `oh harness install ` and `oh tool install ` able to +upgrade one in place: a copy in a root-owned system path is unwritable from a +running sandbox. Consequences worth knowing: - A **first boot on a fresh home mount needs network**. Measured at 21s on a GitHub Actions runner; budget 60–180s on a slower link. The compose healthcheck's `start_period` is 600s to cover it. -- If the registry is unreachable the sandbox still comes up as a usable shell, - with a warning and no agent CLIs. Re-run - `bash .oh/scripts/provision-harnesses.sh` once you have network. +- If the network is unreachable the sandbox still comes up as a usable shell, + with a warning and no agent CLIs — **and no Herdr**, so `oh shell` lands you in + a plain shell and `tmux` is the fallback multiplexer. Re-run + `bash .oh/scripts/provision-defaults.sh` once you have network. - An existing install is never replaced, so the provisioner is a no-op on every - boot after the first. Upgrade deliberately with `oh harness install `. + boot after the first. Upgrade deliberately with `oh harness install ` or + `oh tool install `. +- Every download is pinned and `sha256sum`-verified before it is installed. - npm's cache now lives in the home mount at `~/.npm` and grows across upgrades. `npm cache clean --force` reclaims it. -- Set `OH_PROVISION_HARNESSES=false` to skip the step entirely. +- Set `OH_PROVISION_DEFAULTS=false` to skip the step entirely. Optional CLIs are excluded from the default image; `oh harness install ` flips the matching `install.*` field in `oh.json` and installs it. @@ -324,15 +328,17 @@ Optional CLIs are excluded from the default image; `oh harness install ` f ### DevOps & infrastructure `oh tool list` reports which of these are present, and `oh tool status ` -adds a version where the tool has a verified version flag. Unlike the agent -CLIs above, these are baked into the image, so there is nothing to install. +adds a version where the tool has a verified version flag. Herdr and cloudflared +are `kind: "default"` — provisioned into `~/.local/bin` at boot from a pinned, +checksum-verified binary, and upgradeable in place. The rest are baked into the +image, so there is nothing to install. | Tool | Purpose | |------|---------| -| Herdr (`herdr`) | Default multi-agent terminal workspace; state persists across rebuilds in dedicated volumes | +| Herdr (`herdr`) | Default multi-agent terminal workspace; provisioned at boot, state and binary both persist in the home mount | | Docker CLI + Compose | Container management from inside the sandbox (host docker socket bind-mounted by the base compose) | | GitHub CLI (`gh`) | PRs, issues, releases from the terminal | -| cloudflared | Cloudflare Tunnel client, for exposing a sandbox port (see the `/cloudflared` skill) | +| cloudflared | Cloudflare Tunnel client, for exposing a sandbox port (see the `/cloudflared` skill); provisioned at boot | | tmux | Detachable terminal sessions for long-running agents | | croner | Markdown-frontmatter cron scheduler for autonomous agent tasks | diff --git a/docs/integrations/herdr.md b/docs/integrations/herdr.md index f5d90481..485c8234 100644 --- a/docs/integrations/herdr.md +++ b/docs/integrations/herdr.md @@ -58,6 +58,6 @@ herdr server stop # end a broken Herdr server herdr --no-session # run Herdr without its server/client session ``` -Herdr is pinned in the Open Harness image. Upgrade it by rebuilding against a reviewed Open Harness release rather than self-updating `/usr/local/bin/herdr`. +Herdr is pinned in the tool catalog (`.oh/cli/src/lib/tools/catalog.ts`) and provisioned into `~/.local/bin/herdr` at boot from a checksum-verified binary. Upgrade it by bumping that pin and running `oh tool install herdr`, not by self-updating the binary in place. See the upstream [quick start](https://herdr.dev/docs/quick-start/), [agents guide](https://herdr.dev/docs/agents/), and [configuration reference](https://herdr.dev/docs/configuration/). diff --git a/docs/quickstart.md b/docs/quickstart.md index 024d73cd..5f89d65d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -119,12 +119,13 @@ continue to run independently under tmux. ## Set up agents inside Herdr -The sandbox provisions Claude Code, Codex, and Pi into `~/.local` on first boot — +The sandbox provisions Claude Code, Codex, and Pi — plus the Herdr and cloudflared +tools — into `~/.local` on first boot — they live in the home mount, not the image, so `oh harness install ` upgrades them in place without a rebuild. A first boot on a fresh home mount therefore needs network access and takes a minute or two longer; the sandbox still comes up as a usable shell if the registry is unreachable, and you can retry with -`bash .oh/scripts/provision-harnesses.sh`. OpenCode, DeepAgents, Hermes, and Grok +`bash .oh/scripts/provision-defaults.sh`. OpenCode, DeepAgents, Hermes, and Grok Build are optional image-level installs; T3 Code runs on demand via the `/t3` skill or direct `npx`. Authenticate at least one harness before use.