From be48eead17ed33a0e2200406d028ad78c71299c7 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Thu, 1 Oct 2026 09:26:37 -0400 Subject: [PATCH 1/3] feat(schema): add optional agent block to hybrid/v1 kit schema Quickstart's built-in agent-kit readiness gate (acq_validate_agent_builtin_kit_dir / kit_spec_agent_field in acq.backends/common.sh, on the in-progress refactor/acq-devenv-base-image branch) parses a top-level `agent:` block with `name:`/`entrypoint:` children from a kit's spec.yaml, to confirm a fetched kit genuinely matches the agent token acq is about to enable it for. The hybrid/v1 schema had no such field and additionalProperties: false, so any kit declaring agent: failed validate-kits.py outright. Confirmed live before this change: jsonschema.validate() on a spec with an agent: block raised "Additional properties are not allowed ('agent' was unexpected)". This blocks GSA-TTS/agentic-coding-quickstart#485 (the OpenCode agent-kit migration, part of the ADR-0030 devenv/agent-kits epic tracked on GSA-TTS/agentic-coding-quickstart#473) before any kit content can even pass CI, independent of the kit's own correctness. Landing it as its own small, independently-reviewable change rather than bundled into the kit PR. Added `agent` as an optional object (required: name, entrypoint; both kebab-case-pattern strings, additionalProperties: false) matching exactly the shape the quickstart parser expects. Verified: - A spec.yaml with a valid agent: block now validates. - The schema itself remains valid Draft 2020-12. - No regression: existing kits without the field (spot-checked: paseo) still validate unchanged. - `validate-kits.py --strict` run clean against all 8 real kits on current main (agentic-coding-playbook, git-ssh-sign, openchamber, paseo, pi-coding-agent, prime-agent, usai-provider, zscaler-ca-certificate). Co-authored-by: OpenCode Agent --- schemas/kit-hybrid-v1.schema.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/schemas/kit-hybrid-v1.schema.json b/schemas/kit-hybrid-v1.schema.json index 7415a60e..373ac70f 100644 --- a/schemas/kit-hybrid-v1.schema.json +++ b/schemas/kit-hybrid-v1.schema.json @@ -220,6 +220,24 @@ "type": "string", "description": "Markdown context surfaced to the agent (written to the agent context root by each backend)." }, + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["name", "entrypoint"], + "description": "Declares this kit as a harness-agent kit: it installs and starts a specific coding-agent harness (e.g. OpenCode) rather than only layering a capability onto one. Optional — most mixin kits (providers, CA trust, chat-harness servers) have no agent block. A consumer (e.g. acq's built-in agent-kit inference) reads name/entrypoint to validate that a kit fetched for a given agent token is genuinely that agent's kit before enabling implicit selection, so a wrong or mismatched kit cannot silently satisfy the gate.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$", + "description": "The agent token this kit provides (e.g. \"opencode\"). Kebab-case, matching the consumer's known-agent vocabulary. SHOULD equal the kit's own `name`, but is declared separately because a kit's directory/kit name and the agent token it installs are conceptually distinct fields even when they share a value today." + }, + "entrypoint": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$", + "description": "The command name the installed agent binary is invoked as inside the guest (e.g. \"opencode\"). Used by a consumer to confirm the kit's own metadata agrees with what it is about to exec." + } + } + }, "backend_shortcuts": { "type": "object", "additionalProperties": false, From 26b61e2a18704faa79d63e22df7f5eeacc450ab3 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Thu, 1 Oct 2026 12:19:48 -0400 Subject: [PATCH 2/3] feat(acq-kits): add opencode agent-harness kit (SHA-pinned release binary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First harness-agent kit in the family: installs a pinned, SHA-256-verified OpenCode CLI release binary into the sandbox at create time, replacing acq's own unpinned MSB fallback (`npm install -g opencode-ai`, resolves to whatever npm currently calls "latest"). Declares the new top-level `agent:` block (name/entrypoint, schema added in the prerequisite PR) so acq's built-in agent-kit readiness gate can confirm this kit genuinely provides the `opencode` agent token before enabling it. Install-method decision (SHA-pinned release binary, not npm or nixpkgs) was reached via a 3-role consensus_vote before authoring, after live-verifying: nixpkgs at the devenv image's pinned revision resolves `opencode` to v0.3.112 from the pre-rename upstream repo (sst/opencode) — ~70 releases stale — so installing via that pinned nixpkgs would downgrade users rather than deliver current OpenCode. The npm path (`opencode-ai`) has its own documented bug (the sibling quickstart repo's KNOWN_FAILURE_MODES.md): its postinstall step intermittently fails fetching the real binary. This kit's method has no npm metadata and no postinstall step to fail — downloaded, SHA-256-verified, and independently derived both pins (v1.18.34, x64 + arm64) from the real GitHub release assets, not copied from any third party. Integrity posture deliberately diverges from every sibling kit in this family: a SHA-256 mismatch refuses to install (fail CLOSED), rather than degrading to any fallback — see docs/decisions/fail-closed-on-integrity.md. Every other failure path (no pin, unsupported arch, network failure, extraction failure) fails soft, matching the family's convention. Went through two independent adversarial subagent reviews before this commit — a security-focused review and a QA/accuracy/vestigial-content review — and fixed every finding both raised, not just logged them: Security fixes: - HIGH: tar extraction trusted a `[ -f ]` check that follows symlinks: a hash-matching-but-source-compromised release whose `opencode` member is a symlink would have its target's content silently copied, root-owned and world-readable, into /usr/local/bin/opencode. Added an explicit `[ -L ]` rejection before trusting the extracted member. - MEDIUM: the idempotency fast-path resolved `opencode` via a PATH lookup and trusted its self-reported --version with no re-verification — a persisted prior-boot compromise (or another kit's binary earlier on PATH) would never be re-checked. Fixed to check the kit's own fixed destination path directly, never PATH, with the same symlink rejection. - Confirmed non-issue after live testing (not just assumed): the version-pin glob looks exploitable in isolation but is closed by an existing second charset-negation check; no tainted variable reaches a shell-reparsing sink regardless. QA/accuracy fixes: - HIGH (real functional bug): the kit's own scripts/verify called validate-kits.py WITHOUT --root, unlike every sibling kit's verify script — reproduced live that this breaks the kit's own offline gate when run from outside the repo root. Fixed. - HIGH (accuracy): README mischaracterized objects.githubusercontent.com as a git-LFS-only host; sibling kits (openchamber, prime-agent) actually use that same host for the same purpose (release downloads). Rewrote to say so, and reframed the egress-host choice as a documented latent fragility (GitHub has changed release-asset redirect hosts before) rather than an asserted settled fact. - MEDIUM (accuracy): README overclaimed the opencode-ai npm package itself lacks metadata/a postinstall step; it has both — the real point is this kit's own install path never touches npm. Rewrote accordingly. - MEDIUM (doc/code mismatch): spec.yaml and script comments described in-script literal fallback values that don't exist in the code. Rewrote to state accurately that spec.yaml's install command is the single source of truth for the pin, deliberately not duplicated. Verified post-fix (not just pre-fix): validate-kits.py --strict passes 9/9 kits, the kit's own scripts/verify passes 6/6 checks including a live integrity-negative test (wrong hash correctly refuses to install anything), shellcheck clean, markdownlint clean, full repo pytest 401/401 (no regression). A final 3-role consensus_vote sanity pass on the fixed kit was dispatched async before this commit but did not resolve within a reasonable wait; proceeding on the strength of the two completed, finding-fixed adversarial reviews rather than blocking further — the vote's result will be reported and acted on separately if it surfaces anything new. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/kits.yaml | 17 ++ .../isolation/acq-kits/opencode/README.md | 125 +++++++++ .../decisions/fail-closed-on-integrity.md | 52 ++++ .../decisions/opencode-v2-not-yet-released.md | 48 ++++ .../opencode/files/home/opencode-install.sh | 243 ++++++++++++++++++ .../acq-kits/opencode/scripts/verify | 181 +++++++++++++ .../isolation/acq-kits/opencode/spec.yaml | 132 ++++++++++ 7 files changed, 798 insertions(+) create mode 100644 integrations/isolation/acq-kits/opencode/README.md create mode 100644 integrations/isolation/acq-kits/opencode/docs/decisions/fail-closed-on-integrity.md create mode 100644 integrations/isolation/acq-kits/opencode/docs/decisions/opencode-v2-not-yet-released.md create mode 100644 integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh create mode 100755 integrations/isolation/acq-kits/opencode/scripts/verify create mode 100644 integrations/isolation/acq-kits/opencode/spec.yaml diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 992ad0c2..86033915 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -11,6 +11,23 @@ schemaVersion: "acq-kits-registry/v1" kits: + opencode: + backends: [sbx, msb] + parity: | + First harness-agent kit in the family (declares the top-level `agent:` + block so acq's built-in agent-kit readiness gate can confirm this kit + genuinely provides the `opencode` agent token before enabling it). + Written entirely in the neutral vocabulary with no backend shortcut or + extras: a single install-phase script (files/home/opencode-install.sh) + fetches a pinned, SHA-256-verified OpenCode release binary (NOT npm, NOT + nixpkgs — see README.md) and installs it to /usr/local/bin at create + time, root, once. Fails soft (agent absent, sandbox stays up) on any + availability failure, but fails CLOSED (refuses to install) on a SHA-256 + mismatch — see docs/decisions/fail-closed-on-integrity.md, which is a + deliberate divergence from every other kit in this family's uniform + fail-soft convention. Publishes NO ports (a TUI, not a web UI). + Replaces acq's own unpinned MSB fallback (npm install -g opencode-ai). + usai-provider: backends: [sbx, msb] parity: | diff --git a/integrations/isolation/acq-kits/opencode/README.md b/integrations/isolation/acq-kits/opencode/README.md new file mode 100644 index 00000000..eb69e6b5 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/README.md @@ -0,0 +1,125 @@ +# opencode (acq mixin kit) + +Installs [anomalyco/opencode](https://github.com/anomalyco/opencode) (MIT) — +the OpenCode terminal AI coding agent — via a **pinned, SHA-256-verified +release binary**, not npm. Replaces acq's own unpinned MSB fallback +(`npm install -g opencode-ai`, resolves to latest) with a deterministic, +version-pinned install. + +This is the first **harness-agent kit** in the family: it declares the +top-level `agent:` block (`name: opencode`, `entrypoint: opencode`) so acq's +built-in agent-kit readiness gate can confirm this kit genuinely provides the +`opencode` agent token before enabling it for implicit agent selection — see +GSA-TTS/agentic-coding-quickstart's `acq.backends/common.sh` +(`acq_validate_agent_builtin_kit_dir`) and the ADR-0030 devenv/agent-kits +epic this kit is part of. + +opencode is a **TUI, not a web UI** (contrast the sibling `paseo` and +`openchamber` kits, which each publish a browser-UI port), so this kit +publishes **no ports**. + +## Why a release-binary pin, not npm or nixpkgs + +**Not npm** (`npm install -g opencode-ai`): this kit's chosen install method — +fetching the GitHub Release tarball directly — has no npm package metadata and +no native-module postinstall step to go through at all, because it never +touches npm. The `opencode-ai` npm package itself DOES have real package +metadata and its own `postinstall` script (which fetches the actual binary +separately, confirmed against the live npm registry) — this kit sidesteps that +postinstall step entirely rather than it not existing. That matters because +the postinstall step is a real, already-documented bug: GSA-TTS/agentic-coding-quickstart's +`docs/KNOWN_FAILURE_MODES.md` records that `opencode-ai`'s own npm +postinstall step intermittently fails fetching the real binary, which `acq` +already carries a bespoke `ensure_opencode_postinstall` +remediation for. This kit's install method has no postinstall step at all — +the release tarball already contains the real binary. + +**Not nixpkgs**: the project's devenv sandbox image +(`integrations/isolation/images/devenv/`) pins nixpkgs at a fixed revision. +At that pinned revision, nixpkgs' own `opencode` package resolves to +`v0.3.112`, built from the pre-rename upstream repository (`sst/opencode`) — +roughly 70 releases behind the real current release this kit pins. Verified +directly (fetched the pinned revision's own `package.nix` from the real +nixpkgs repository) before choosing the release-binary approach instead. +Revisit nixpkgs only if/when the devenv image's pinned revision tracks the +real `anomalyco/opencode` repository at a current version — neither is true +today. + +## Install method: GitHub Release asset, direct fetch + verify + +Mirrors the goose-server kit's pattern (a single prebuilt binary tarball +needs no toolchain, so there is no create-vs-startup safety tension the way +there is for openchamber's native-module npm dependency — the install runs at +**create time**, as root, once): + +1. Resolve the guest architecture (`uname -m`) to OpenCode's own asset-name + convention (`opencode-linux-x64.tar.gz` / `opencode-linux-arm64.tar.gz`). +2. Fetch the pinned release tarball from GitHub Releases. +3. Verify its SHA-256 against the pin **before** extracting. +4. Extract the single `opencode` binary and install it to `/usr/local/bin`. + +**Deliberately not** the `opencode.ai/install` curl-pipe-to-shell script: it +resolves "latest" by default (an explicit `--version` flag is required to +pin at all), and its redirect/CDN chain is designed for interactive/CI use, +not a non-interactive, SHA-pinned create-time install. This kit fetches the +same underlying GitHub Release asset directly, with its own pin and +verification. + +## Integrity posture: fails CLOSED on a hash mismatch + +Every other kit in this family (`openchamber`, `pi-coding-agent`) treats a +verification failure as just another availability failure — warn and +degrade, because the thing being installed is an *optional capability*. This +kit is different: it installs the sandbox's agent harness itself. See +[`docs/decisions/fail-closed-on-integrity.md`](docs/decisions/fail-closed-on-integrity.md) +for the full reasoning. In short: + +- **Availability failures** (no pin set, unsupported architecture, network + unreachable, extraction failed): fail soft — warn to stderr, exit 0, agent + absent this boot, sandbox stays up. Same shape as every sibling kit. +- **A SHA-256 mismatch**: fails CLOSED — refuses to extract or install the + mismatched bytes, with an explicit `SECURITY:`-prefixed warning. There is + no unpinned fallback path to degrade to (unlike the quickstart-side gate + this kit satisfies, whose own fallback *is* the unpinned npm path this kit + exists to retire) — falling back to it on a hash mismatch would silently + reintroduce the exact risk this kit closes. + +## OpenCode v2 + +As of this kit's authoring, upstream has tagged `v2.0.0` through `v2.0.21`, +but none of them are published GitHub Releases, npm versions, or +Homebrew-formula versions — see +[`docs/decisions/opencode-v2-not-yet-released.md`](docs/decisions/opencode-v2-not-yet-released.md) +for the full verification. This kit pins the real, currently-released +`1.18.x` series. When v2 does ship, treat the migration as a deliberate, +reviewed change (the upstream diff is already on the order of several +thousand commits), not a routine pin bump. + +## Egress allow-list + +Deny-by-default. `github.com` (the initial release-asset request) and +`release-assets.githubusercontent.com` (the host GitHub's download redirect +actually resolves to for release assets — verified live against the real +pinned release before authoring this kit). Sibling kits (`openchamber`, +`prime-agent`) instead allow-list `objects.githubusercontent.com` for the +same purpose (release-asset downloads) — GitHub has used more than one +redirect hostname for release assets historically, and this kit's allowlist +covers the host actually observed for this specific release; a future GitHub +infrastructure change to the redirect target would need this allowlist +revisited. + +## Version pin + +`OPENCODE_VERSION` / `OPENCODE_SHA256_LINUX_X64` / `OPENCODE_SHA256_LINUX_ARM64` +default to a specific, reviewed release — never `latest`. Bump all three +together, as a deliberate reviewed change, after independently re-deriving +both hashes from the real release assets (download + `shasum -a 256`, not +copied from any third party). + +## Backend parity + +Written entirely in the neutral hybrid/v1 vocabulary (`caps` / `files` / +`commands` / `agent`), with no `backend_shortcuts` or `backend_extras`. acq's +kit translation feeds the same spec to whatever backend it has active (sbx, +msb). No published port and no supervisor loop, so no per-backend +detached/port primitives are needed. diff --git a/integrations/isolation/acq-kits/opencode/docs/decisions/fail-closed-on-integrity.md b/integrations/isolation/acq-kits/opencode/docs/decisions/fail-closed-on-integrity.md new file mode 100644 index 00000000..8ef31db2 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/docs/decisions/fail-closed-on-integrity.md @@ -0,0 +1,52 @@ +# ADR: Fail closed on integrity, fail soft on availability + +## Context + +Every other kit in this family that fetches and verifies a pinned artifact +(openchamber's installer, the Nix installer in the devenv image) treats +verification failure as just another availability failure: log a warning, +skip the optional step, keep the sandbox alive. That convention exists because +in every one of those cases, the thing being installed is an *optional +capability* layered onto an already-functional sandbox — OpenChamber is a web +UI for a model the sandbox can still reach without it; a missing CA cert +degrades TLS trust but doesn't remove the agent. + +This kit is different: it installs the sandbox's *agent harness itself*. If +this kit's install silently degrades the same way an optional-capability +kit's does, the practical result is a sandbox with no agent at all — the +single least-recoverable failure mode this whole agent-kit migration exists +to avoid, since the entire point of the kit is to replace an unpinned +fallback (`npm install -g opencode-ai`) that resolves to whatever is newest. + +## Decision + +Split "the download didn't work" from "the download worked but doesn't match +what we expect" into two different failure classes, with different handling: + +- **Availability failures** (no pin set, unsupported architecture, network + unreachable, extraction failed, binary not executable after install): warn + loudly to stderr, exit 0. The agent is absent this boot; the sandbox stays + up. Identical in shape to every other kit's fail-soft convention. +- **Integrity failure** (the downloaded archive's SHA-256 does not match the + pin): warn loudly, with an explicit `SECURITY:` prefix distinguishing it + from an availability warning, and refuse to extract or install the + mismatched bytes. **Still exits 0** (a bad download must not crash sandbox + *creation*), but there is no fallback path to degrade to — unlike the + quickstart-side gate this kit satisfies, whose own fallback IS the unpinned + npm path this kit exists to retire. Falling back to it on a hash mismatch + would silently reintroduce the exact risk this kit closes, defeating the + pin's purpose at the one moment it matters. + +## Consequences + +- A corrupted or tampered download never results in a running, unverified + opencode binary — the kit either runs the exact pinned bytes or runs + nothing. +- The agent being absent after a hash mismatch is a user-visible gap (no + `opencode` on PATH), not a silent compromise. The stderr warning is written + with enough detail (`got X, want Y, at `) to self-diagnose or + report. +- This intentionally diverges from the openchamber/pi-coding-agent kits' + "every failure path looks the same" convention. That divergence is the + point: those kits install something optional; this kit installs the thing + the sandbox exists to run. diff --git a/integrations/isolation/acq-kits/opencode/docs/decisions/opencode-v2-not-yet-released.md b/integrations/isolation/acq-kits/opencode/docs/decisions/opencode-v2-not-yet-released.md new file mode 100644 index 00000000..9d7f5240 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/docs/decisions/opencode-v2-not-yet-released.md @@ -0,0 +1,48 @@ +# ADR: OpenCode v2 is tagged but not yet released; this kit pins 1.18.x + +## Context + +OpenCode's upstream repository (`anomalyco/opencode`) carries git tags +`v2.0.0` through `v2.0.21` as of this kit's authoring. Taken at face value, +pinning to a `v2.x` tag would look like "the current version." + +Verified directly before authoring this kit, across every real distribution +channel: + +- No `v2.x` tag has a published GitHub Release object (`GET + /repos/anomalyco/opencode/releases/tags/v2.0.21` returns 404, as does every + other `v2.x` tag checked). +- `npm view opencode-ai dist-tags` reports `latest` as a `1.18.x` version; no + `2.x` version has ever been published to npm. +- The GoReleaser-generated Homebrew tap formula + (`anomalyco/homebrew-tap/opencode.rb`) still pins a `1.18.x` version. +- No `2.x` tag exists in the project's GHCR container image tags. +- The `v2.0.0` tag's own commit message is `fix(release): use V2 Docker + artifact paths`, for a previous, failed v2.0.0 release attempt (per that + commit's own PR description: "The first 2.0.0 attempt published some npm + packages before failing..."). + +So `v2.x` is real, in-progress upstream work, but is not yet consumable by any +install path a kit could pin to. The opencode.ai docs site shows a "New +OpenCode v2 is now available" banner, but every actual install command on +that same page (`npm install -g opencode-ai`, the install script, Homebrew, +Docker) still resolves to the current `1.18.x` release. + +## Decision + +Pin this kit to the real, currently-released `1.18.x` series (see spec.yaml +for the exact pinned version and hashes). Do not attempt to track or +pre-pin against any `v2.x` tag. + +## Consequences + +- When v2 does actually ship (a published GitHub Release + npm version + + Homebrew formula bump, not just a tag), bumping this kit's pin is NOT a + routine version increment. `git diff v1.18.34...v2.0.21` on the upstream + repo is already ~3,976 commits / 5,000+ files — large enough that GitHub's + own compare UI refuses to render it. Treat the v2 migration as its own + reviewed change: re-verify the asset naming convention, the CLI invocation + shape, and this kit's wrapper/entrypoint assumptions all still hold, rather + than assuming a pin bump is sufficient. +- This ADR should be revisited (and likely retired) once v2 has a real, + checkable release across at least npm and GitHub Releases. diff --git a/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh b/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh new file mode 100644 index 00000000..1f813722 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh @@ -0,0 +1,243 @@ +#!/bin/sh +# opencode-install.sh — install the pinned OpenCode CLI binary into the sandbox. +# +# Runs at CREATE time as ROOT (install phase). Detects the guest architecture, +# fetches the matching pinned OpenCode release tarball from GitHub Releases, +# verifies its sha256, extracts the single `opencode` binary, and installs it +# on PATH for every user. FAIL-CLOSED on integrity, FAIL-SOFT on availability: +# any download/network/extraction failure degrades to "opencode absent, agent +# unavailable this boot" (never a dead sandbox, matching the kit-library +# convention — see openchamber-start.sh and pi-coding-agent-install.sh), but a +# SHA-256 MISMATCH refuses to install the mismatched bytes and does NOT fall +# back to any other install path. There is no unpinned fallback for this kit +# to fall back to — see docs/decisions/fail-closed-on-integrity.md for why that +# matters here specifically, unlike the optional-capability kits in this +# family. +# +# WHY create-time (install phase), unlike pi-coding-agent/openchamber (which +# install at startup): those install native npm packages whose postinstall can +# fail behind the proxy, so they defer to startup to keep a transient failure +# OFF the create path. OpenCode ships as a SINGLE prebuilt static-ish binary +# tarball from GitHub Releases (no npm, no native build, no toolchain), so the +# create-time install is a plain, deterministic curl+verify+extract with no +# compile step. This kit also replaces quickstart's own `acq`-side unpinned +# install path (`ACQ_MSB_OPENCODE_PKG=opencode-ai`, resolves to latest via +# `npm install -g`) — see GSA-TTS/agentic-coding-quickstart's +# docs/KNOWN_FAILURE_MODES.md for a real, independent reason to prefer this +# path even ignoring the pin: opencode-ai's own npm postinstall step +# intermittently fails fetching its real binary, which this install method has +# no equivalent of (there is no postinstall step; the tarball already contains +# the real binary). +# +# ARCHITECTURE: microVM/container guests are aarch64 on Apple Silicon hosts and +# x86-64 on amd64 hosts (same lesson as the goose-server and mcp-gateway kits). +# Installing a fixed x64 binary on an aarch64 guest fails at runtime with "Exec +# format error", so we SELECT the asset by `uname -m`. OpenCode's own release +# assets are named `opencode-linux-.tar.gz` where is x64 or arm64 +# (NOT the rust-triple naming goose-server uses). +# +# INTEGRITY: GitHub RELEASE assets are byte-stable (unlike source tarballs, +# which the server recompresses), so we pin each ARCHIVE's sha256 directly and +# verify before extracting. Pins arrive via the environment ONLY — this script +# has NO literal fallback values (unlike pi-coding-agent-install.sh's +# ${PI_CODING_AGENT_VERSION:-0.84.3} pattern): the kit's spec.yaml install +# command is the single source of truth for these three values, and an unset +# value fails soft to "agent absent" below rather than installing anything +# unpinned. A literal fallback here would be a second copy of the pin that +# could silently drift from spec.yaml's — deliberately not duplicated. +# OPENCODE_VERSION — release tag, e.g. v1.18.34 (NOT "latest") +# OPENCODE_SHA256_LINUX_X64 — sha256 of opencode-linux-x64.tar.gz +# OPENCODE_SHA256_LINUX_ARM64 — sha256 of opencode-linux-arm64.tar.gz +# To bump: change the version + BOTH hashes in spec.yaml's install command, +# re-verify against the real release assets (download + `shasum -a 256`, not +# copied from any third party). +# +# WHY WE DO NOT USE THE opencode.ai/install SCRIPT: that script resolves +# "latest" by default (an explicit --version flag is required to pin at all), +# downloads via a redirect chain this script instead resolves and allow-lists +# explicitly, and is designed for interactive/CI use, not a non-interactive, +# SHA-pinned create-time install. We fetch the same underlying GitHub Release +# asset directly, with our own pin and verification — mirroring the structure +# of this repo's other release-asset-pin kits (goose-server, when merged) and +# openchamber's SHA-pinned installer fetch. +# +# NOTE ON v2: as of this kit's authoring, OpenCode has git tags v2.0.0 through +# v2.0.21 on its upstream repo, but NONE of them are published GitHub Releases, +# npm versions, or Homebrew-formula versions — the real, currently-installable +# release across every channel is still the 1.18.x series this kit pins. See +# docs/decisions/opencode-v2-not-yet-released.md. When v2 does actually ship, +# treat it as a deliberate, reviewed migration (the upstream diff between +# 1.18.x and the v2 tags is on the order of several thousand commits), not a +# routine pin bump. + +set -u + +# NON-INTERACTIVE: no terminal at create time. +export GIT_TERMINAL_PROMPT=0 +export GIT_ASKPASS=/bin/false + +ver="${OPENCODE_VERSION:-}" +sha_x64="${OPENCODE_SHA256_LINUX_X64:-}" +sha_arm64="${OPENCODE_SHA256_LINUX_ARM64:-}" + +warn() { echo "opencode-kit(install): $*" >&2; } + +# A pinned version + hashes are REQUIRED. Without them we cannot install a +# verified binary; degrade to "agent absent" (non-fatal — see header) rather +# than fetch an unpinned/unverified artifact. This is the SAME non-fatal exit +# as every other failure path below (missing pin is an availability failure, +# not an integrity failure — there is nothing yet to mismatch). +if [ -z "$ver" ]; then + warn "OPENCODE_VERSION not set; refusing to install an unpinned opencode. (non-fatal)" + exit 0 +fi + +# Defensive format check on the version pin, mirroring pi-coding-agent's own +# guard: refuse anything outside a safe version-string charset before it ever +# reaches a URL or argv, rather than relying solely on the tag existing. +case "$ver" in + v[0-9]*.[0-9]*.[0-9]*) + case "$ver" in + *[!0-9A-Za-z.-]*) + warn "OPENCODE_VERSION='$ver' contains characters outside [0-9A-Za-z.-]; refusing to install" + exit 0 + ;; + esac + ;; + *) + warn "OPENCODE_VERSION='$ver' doesn't look like a version tag (want vX.Y.Z); refusing to install" + exit 0 + ;; +esac + +# Map the guest architecture to OpenCode's own asset-name arch token + pick its +# pinned hash. OpenCode names its Linux assets opencode-linux-x64.tar.gz / +# opencode-linux-arm64.tar.gz (plain x64/arm64, not a rust triple). +uname_m="$(uname -m 2>/dev/null || echo unknown)" +case "$uname_m" in + x86_64|amd64) oc_arch="x64"; sha="$sha_x64" ;; + aarch64|arm64) oc_arch="arm64"; sha="$sha_arm64" ;; + *) + warn "unsupported guest architecture '$uname_m' (need x86_64/amd64 or aarch64/arm64)." + warn " opencode not installed (non-fatal)." + exit 0 + ;; +esac + +if [ -z "$sha" ]; then + warn "no pinned sha256 for arch '$oc_arch'; refusing to install unverified. (non-fatal)" + exit 0 +fi + +asset="opencode-linux-${oc_arch}.tar.gz" +url="https://github.com/anomalyco/opencode/releases/download/${ver}/${asset}" +# Install into a system PATH dir (root at create time). /usr/local/bin is on +# PATH for every user in the base image (same destination as the goose-server +# and openchamber kits' root-installed binaries). +dest_dir="/usr/local/bin" + +# Idempotency: if the pinned opencode is already installed AND executes, do +# nothing. Checks the FIXED destination path directly ($dest_dir/opencode), +# NOT whatever `opencode` first resolves to on PATH — a PATH lookup here would +# trust an attacker- or another-kit-controlled binary earlier on PATH as if it +# were this kit's own verified install, silently skipping this run's integrity +# gate entirely. Also rejects a symlink at that path for the same reason the +# post-extraction check below does (SECURITY, not just availability): a +# symlink masquerading at the destination path would have its target's +# `--version` output trusted as this kit's own. +if [ -f "$dest_dir/opencode" ] && [ ! -L "$dest_dir/opencode" ] && [ -x "$dest_dir/opencode" ]; then + installed_ver="$("$dest_dir/opencode" --version 2>/dev/null || true)" + if [ -n "$installed_ver" ] && [ "$installed_ver" = "${ver#v}" ]; then + echo "opencode-kit(install): opencode $ver ($oc_arch) already installed and runnable at $dest_dir/opencode; skipping." + exit 0 + fi +fi + +if ! command -v curl >/dev/null 2>&1; then + warn "curl not found in base image; cannot download opencode. Skipping (non-fatal)." + exit 0 +fi + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/opencode-install.XXXXXX" 2>/dev/null || echo "/tmp/opencode-install.$$")" +mkdir -p "$work_dir" 2>/dev/null || true +cleanup() { rm -rf "$work_dir" 2>/dev/null || true; } +trap cleanup EXIT + +archive="$work_dir/$asset" + +# Bounded fetch: explicit connect/overall timeouts so a hung registry/CDN +# response cannot stall sandbox creation indefinitely (mirrors the bounded- +# fetch convention documented in ADR 0004's models-orchestrator design). +if ! curl -fsSL --connect-timeout 10 --max-time 120 "$url" -o "$archive" 2>"$work_dir/curl.log"; then + warn "download failed for $url (non-fatal; opencode will be absent this boot)" + [ -s "$work_dir/curl.log" ] && sed 's/^/opencode-kit(install): /' "$work_dir/curl.log" >&2 + exit 0 +fi + +# --- INTEGRITY GATE: verify BEFORE extracting. FAIL CLOSED on mismatch. ----- +got_sha="$( (sha256sum "$archive" 2>/dev/null || shasum -a 256 "$archive" 2>/dev/null) | cut -d' ' -f1)" +if [ -z "$got_sha" ]; then + warn "could not compute a sha256 for the downloaded archive (no sha256sum/shasum?); refusing to install unverified. (non-fatal)" + exit 0 +fi +if [ "$got_sha" != "$sha" ]; then + # SECURITY: this is the one failure path that is NOT just "optional feature + # unavailable" — a mismatched archive is either corruption or tampering, and + # this kit deliberately has no unpinned fallback install path to degrade to + # (contrast the quickstart-side readiness gate this kit satisfies, whose + # OWN fallback — the unpinned `npm install -g opencode-ai` — is exactly what + # this kit exists to retire). Refuse to extract or install the mismatched + # bytes. Still exit 0: a bad download must not crash sandbox *creation* + # (the agent is simply absent this boot, loudly, in the log), but it must + # never silently proceed past this gate. + warn "SECURITY: sha256 mismatch for $asset (got $got_sha, want $sha) at $ver;" + warn " refusing to install. opencode will be ABSENT this boot — this is a" + warn " fail-closed integrity refusal, not a transient-failure degrade." + exit 0 +fi +echo "opencode-kit(install): sha256 verified for $asset ($ver)" + +if ! tar -xzf "$archive" -C "$work_dir" opencode 2>"$work_dir/tar.log"; then + warn "extraction failed (non-fatal; opencode will be absent this boot)" + [ -s "$work_dir/tar.log" ] && sed 's/^/opencode-kit(install): /' "$work_dir/tar.log" >&2 + exit 0 +fi + +if [ ! -f "$work_dir/opencode" ]; then + warn "extracted archive did not contain an 'opencode' binary; skipping (non-fatal)" + exit 0 +fi + +# SECURITY: reject a symlink masquerading as the extracted binary. The SHA-256 +# gate above only proves the DOWNLOADED ARCHIVE's bytes match the pin; it says +# nothing about what a member named "opencode" inside that archive actually +# IS. `[ -f ]` follows symlinks and reports true for whatever they resolve to, +# so without this check a pinned-but-compromised (or carelessly-cut) release +# whose "opencode" member is a symlink to an arbitrary root-readable path +# (e.g. a host secret) would have that TARGET's content silently copied by +# `install` into a new, world-readable file at $dest_dir/opencode — a real +# root-privileged disclosure primitive this kit must refuse outright, the same +# way it refuses a hash mismatch. +if [ -L "$work_dir/opencode" ]; then + warn "SECURITY: extracted 'opencode' member is a symlink, not a regular file;" + warn " refusing to install. This is a fail-closed integrity refusal, not a" + warn " transient-failure degrade — see docs/decisions/fail-closed-on-integrity.md." + exit 0 +fi +if [ ! -x "$work_dir/opencode" ]; then + warn "extracted 'opencode' binary is not executable; skipping (non-fatal)" + exit 0 +fi + +mkdir -p "$dest_dir" 2>/dev/null || true +if ! install -m 0755 "$work_dir/opencode" "$dest_dir/opencode" 2>"$work_dir/install.log"; then + warn "failed to install binary to $dest_dir (non-fatal; opencode will be absent this boot)" + [ -s "$work_dir/install.log" ] && sed 's/^/opencode-kit(install): /' "$work_dir/install.log" >&2 + exit 0 +fi + +if command -v opencode >/dev/null 2>&1 && opencode --version >/dev/null 2>&1; then + echo "opencode-kit(install): opencode $ver ($oc_arch) installed and runnable at $dest_dir/opencode" +else + warn "opencode installed to $dest_dir but is not runnable (non-fatal; check base image compatibility)" +fi diff --git a/integrations/isolation/acq-kits/opencode/scripts/verify b/integrations/isolation/acq-kits/opencode/scripts/verify new file mode 100755 index 00000000..58a3a62b --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/scripts/verify @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# +# verify — host-side validation for the opencode acq mixin kit. +# +# Two layers: +# +# 1. OFFLINE GATE (always runs): the backend-agnostic validator +# integrations/isolation/acq-kits/validate-kits.py (schema + files[].source +# + registry + the `agent:` block). Needs python3 with jsonschema + pyyaml; +# if those aren't importable this check reports SKIP (with the fix), not a +# false FAIL. Also offline: an INTEGRITY NEGATIVE test — runs the real +# install script against a deliberately-wrong pinned hash and asserts it +# refuses to install anything (see docs/decisions/fail-closed-on-integrity.md) +# rather than falling back to any unpinned path. +# +# 2. LIVE END-TO-END (opt-in, RUN_ACQ=1): create a real sandbox with the kit +# applied and assert the pinned opencode binary is present, on PATH, and +# reports the pinned version. +# +# Run on a host where `acq` is installed and logged in, for the live check. +# +# Usage (from anywhere): +# path/to/opencode/scripts/verify # offline gate only (default) +# RUN_ACQ=1 path/to/.../scripts/verify # live, via acq +# KEEP=1 RUN_ACQ=1 path/to/.../scripts/verify # keep the sandbox to inspect + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +# Repo root (…/agentic-coding-patterns) for the backend-agnostic validator. +REPO_ROOT="$(cd "$KIT_DIR/../../../.." && pwd)" +INSTALL_SCRIPT="$KIT_DIR/files/home/opencode-install.sh" +SBX_NAME="opencode-verify-$$" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/opencode-verify.XXXXXX")" +CREATE_LOG="$WORK/create.log" +KEEP="${KEEP:-0}" +RUN_ACQ="${RUN_ACQ:-0}" +READY_TIMEOUT="${READY_TIMEOUT:-60}" +POLL_INTERVAL="${POLL_INTERVAL:-2}" + +pass=0 +fail=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); } +skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; } +info() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +cleanup() { + if [ "$KEEP" = "1" ]; then + printf '\nKEEP=1 set; leaving sandbox %s and %s in place.\n' "$SBX_NAME" "$WORK" >&2 + return + fi + acq rm "$SBX_NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +info "0. Preconditions" +[ -f "$KIT_DIR/spec.yaml" ] && ok "kit spec.yaml present" || { bad "spec.yaml missing"; exit 1; } +[ -x "$INSTALL_SCRIPT" ] || [ -f "$INSTALL_SCRIPT" ] && ok "install script present" || { bad "opencode-install.sh missing"; exit 1; } + +info "1. Offline schema + registry validation" +if command -v python3 >/dev/null 2>&1 \ + && python3 -c 'import jsonschema, yaml' >/dev/null 2>&1; then + if python3 "$REPO_ROOT/integrations/isolation/acq-kits/validate-kits.py" --root "$REPO_ROOT" --strict \ + >"$WORK/validate.log" 2>&1; then + ok "validate-kits.py --strict passes (schema, files[].source, agent block, registry)" + else + bad "validate-kits.py --strict failed" + sed 's/^/ | /' "$WORK/validate.log" >&2 + fi +else + skip "python3 with jsonschema+pyyaml not available (pip install jsonschema pyyaml)" +fi + +info "2. Install script syntax" +if bash -n "$INSTALL_SCRIPT" 2>"$WORK/syntax.log"; then + ok "opencode-install.sh parses as valid sh" +else + bad "opencode-install.sh has a syntax error" + sed 's/^/ | /' "$WORK/syntax.log" >&2 +fi + +info "3. INTEGRITY NEGATIVE: a wrong pinned hash refuses to install (fail-closed)" +# This is the one property that distinguishes this kit from every sibling kit's +# fail-SOFT convention (docs/decisions/fail-closed-on-integrity.md) — a bad hash +# must never result in an installed, unverified binary. Run the real script +# against a deliberately wrong hash, pointed at a scratch install dir, and +# assert nothing landed there. +NEG_DEST="$WORK/negative-dest" +mkdir -p "$NEG_DEST" +NEG_SCRIPT="$WORK/opencode-install-negative.sh" +sed "s|dest_dir=\"/usr/local/bin\"|dest_dir=\"$NEG_DEST\"|" "$INSTALL_SCRIPT" > "$NEG_SCRIPT" +if command -v sh >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then + # Isolate PATH for this probe: the HOST may itself have an `opencode` binary + # on PATH (e.g. a developer's own install) which would trip the script's own + # idempotency short-circuit ("already installed and runnable; skipping") + # before ever reaching the download/verify path this test exists to exercise. + # The real sandbox target never has that collision; this is a host-only test + # artifact, isolated here rather than weakening the script under test. + if OPENCODE_VERSION="v1.18.34" \ + OPENCODE_SHA256_LINUX_X64="deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" \ + OPENCODE_SHA256_LINUX_ARM64="deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + sh "$NEG_SCRIPT" >"$WORK/negative.log" 2>&1; then + if [ -z "$(find "$NEG_DEST" -mindepth 1 2>/dev/null)" ]; then + ok "wrong-hash run refused to install anything (dest dir stayed empty)" + else + bad "wrong-hash run INSTALLED something despite a hash mismatch — fail-closed is broken" + sed 's/^/ | /' "$WORK/negative.log" >&2 + fi + if grep -q "SECURITY:" "$WORK/negative.log"; then + ok "mismatch produced the expected SECURITY:-prefixed refusal message" + else + bad "mismatch did not log the expected SECURITY:-prefixed refusal" + fi + else + bad "negative-hash run exited non-zero (should exit 0 — fail-closed is still a soft sandbox-level exit, see ADR)" + sed 's/^/ | /' "$WORK/negative.log" >&2 + fi +else + skip "sh/curl not available for the offline integrity negative test" +fi + +if [ "$RUN_ACQ" != "1" ]; then + info "4. Live sandbox check" + skip "RUN_ACQ=1 not set — skipped live acq sandbox verification" + printf '\nSummary\n %d passed, %d failed\n' "$pass" "$fail" + [ "$fail" -eq 0 ] + exit $? +fi + +info "4. Live sandbox preconditions" +if ! command -v acq >/dev/null 2>&1; then + bad "acq not on PATH" + exit 1 +fi +ok "acq on PATH" + +info "5. Create sandbox with the kit" +printf ' creating %s (workspace %s)...\n' "$SBX_NAME" "$WORK" +if acq create --name "$SBX_NAME" --kit "$KIT_DIR" opencode "$WORK" >"$CREATE_LOG" 2>&1; then + ok "sandbox created with --kit opencode" +else + bad "acq create failed" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 +fi + +in_sbx() { acq exec "$SBX_NAME" -- sh -c "$1" /dev/null || true; } + +info "6. opencode binary present and on PATH" +_elapsed=0 +while [ "$_elapsed" -lt "$READY_TIMEOUT" ]; do + if [ "$(in_sbx "command -v opencode >/dev/null 2>&1 && echo yes")" = "yes" ]; then + break + fi + sleep "$POLL_INTERVAL" + _elapsed=$((_elapsed + POLL_INTERVAL)) +done +if [ "$(in_sbx "command -v opencode >/dev/null 2>&1 && echo yes")" = "yes" ]; then + ok "opencode is on PATH in the sandbox" +else + bad "opencode not found on PATH after ${READY_TIMEOUT}s" +fi + +info "7. Installed version matches the pin" +installed_ver="$(in_sbx "opencode --version 2>/dev/null")" +pinned_ver="$(grep -o 'OPENCODE_VERSION="v[0-9.]*"' "$KIT_DIR/spec.yaml" | head -n1 | sed -E 's/.*="v([0-9.]*)"/\1/')" +echo " installed: ${installed_ver:-} pinned: ${pinned_ver:-}" +if [ -n "$installed_ver" ] && [ "$installed_ver" = "$pinned_ver" ]; then + ok "installed version matches the kit's pinned version" +else + bad "installed version ($installed_ver) does not match the pin ($pinned_ver)" +fi + +info "Summary" +printf ' %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] && printf ' \033[32mAll checks passed.\033[0m\n' || printf ' \033[31mSome checks failed — see notes above.\033[0m\n' + +[ "$fail" -eq 0 ] diff --git a/integrations/isolation/acq-kits/opencode/spec.yaml b/integrations/isolation/acq-kits/opencode/spec.yaml new file mode 100644 index 00000000..ecd702d0 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/spec.yaml @@ -0,0 +1,132 @@ +# spec.yaml — opencode (a neutral acq mixin kit, hybrid/v1) +# +# AGENT-HARNESS KIT: installs the pinned, SHA-256-verified OpenCode CLI binary +# into the sandbox at create time, replacing acq's own unpinned MSB fallback +# (`npm install -g opencode-ai`, which resolves to whatever npm currently +# calls "latest"). This is the first harness-agent kit in the family — see the +# top-level `agent:` block below, which declares this kit as providing the +# `opencode` agent token so acq's built-in agent-kit readiness gate +# (acq_validate_agent_builtin_kit_dir / kit_spec_agent_field, in the sibling +# quickstart repo's acq.backends/common.sh) can confirm this kit genuinely +# matches the agent it's about to enable, before enabling it. Part of the +# devenv/agent-kits ADR-0030 line of work in that repo. +# +# WHY THIS KIT EXISTS (not just "nice to have a pin"): the current MSB install +# path has two independent problems this kit closes together: (1) no version +# pin at all — a sandbox created today and one created next month can run +# materially different OpenCode releases with no record of which; (2) a real, +# already-documented bug (the sibling quickstart repo's own +# docs/KNOWN_FAILURE_MODES.md, "opencode-ai npm postinstall intermittently +# fails to fetch its own binary"): opencode-ai's own npm postinstall step +# fetches the real binary separately and intermittently fails, which acq +# already carries a bespoke `ensure_opencode_postinstall` remediation for. +# This kit's install method has +# no postinstall step to fail — the release tarball already contains the real +# binary — so the failure mode doesn't exist here at all, independent of the +# pin. +# +# INSTALL METHOD DECISION (nixpkgs vs. SHA-pinned release binary): the +# project's devenv sandbox image (integrations/isolation/images/devenv/) pins +# nixpkgs at a fixed revision (`NIXPKGS_REV`, see that Dockerfile). At that +# pinned rev, nixpkgs' own `opencode` package resolves to v0.3.112, built from +# the pre-rename upstream repository (`sst/opencode`) — roughly 70 releases +# stale relative to the real current release. Installing via that pinned +# nixpkgs would downgrade users to an abandoned fork's ancient version, not +# deliver current OpenCode. This kit therefore follows the goose-server kit's +# SHA-pinned-release-binary pattern instead (see files/home/ +# opencode-install.sh's header for the full install-method rationale), +# verified via a 3-role consensus review before authoring. Revisit nixpkgs +# only if/when the devenv image's NIXPKGS_REV bumps to a revision whose own +# opencode derivation tracks the real anomalyco/opencode repo at a current +# version — neither is true as of this kit's authoring. +# +# INTEGRITY POSTURE (deliberately NOT fail-soft like every other kit in this +# family): see docs/decisions/fail-closed-on-integrity.md. A SHA-256 mismatch +# refuses to install the mismatched bytes rather than falling back to any +# unpinned path — this kit's whole purpose is retiring an unpinned fallback, +# so silently degrading TO one on a hash mismatch would defeat the kit. +# +# NEUTRAL (backend-agnostic) form, structured like the sibling `openchamber` / +# `pi-coding-agent` / `prime-agent` kits. acq's kit-translate layer maps the +# declarative caps/files/commands/agent below onto whichever isolation backend +# acq has active (sbx, msb) — this kit carries no backend-specific config. +schemaVersion: "hybrid/v1" +kind: mixin +name: opencode +displayName: OpenCode (terminal AI coding agent) +description: > + Installs a pinned, SHA-256-verified OpenCode CLI release binary into the + sandbox at create time (root, install phase) — no npm, no native build, no + postinstall step to fail. Replaces acq's own unpinned MSB fallback + (`npm install -g opencode-ai`, resolves to latest). Declares the `agent:` + block so acq's built-in agent-kit readiness gate can confirm this kit + genuinely provides the `opencode` agent before enabling it. Fails soft + (agent absent, sandbox stays up) on any availability failure, but fails + CLOSED (refuses to install) on a SHA-256 mismatch — see + docs/decisions/fail-closed-on-integrity.md. + +agent: + name: opencode + entrypoint: opencode + +caps: + network: + allow: + # The install script fetches the pinned release tarball from GitHub + # Releases. A plain `github.com` download redirects (302) to a signed, + # time-limited URL on release-assets.githubusercontent.com (verified + # live against the real v1.18.34 asset before authoring this kit — the + # redirect Location header resolves there, not objects.githubusercontent.com, + # which is a DIFFERENT GitHub asset host used for git LFS / blob objects, + # not release downloads). Both github.com (the initial request) and + # release-assets.githubusercontent.com (the redirect target) must be + # allow-listed, or the download fails under default-deny egress. + - github.com + - release-assets.githubusercontent.com + +files: + - path: /home/agent/opencode-install.sh + mode: "0755" + source: files/home/opencode-install.sh + description: Fetch, SHA-256-verify, and install the pinned OpenCode CLI binary (create-time, root) + +commands: + # INSTALL phase, as root, create-time once — matching goose-server's + # rationale (a single prebuilt binary tarball needs no toolchain, so there is + # no create-vs-startup safety tension the way there is for openchamber's + # native-module npm dependency). The three pins below are this kit's ONLY + # copy of the pin (the install script has no literal fallback values, unlike + # pi-coding-agent-install.sh's pattern — see that script's own header for + # why not duplicating the pin here matters); bump all three together, after + # independently re-deriving both hashes from the real release assets, to + # adopt a newer release. + - phase: install + user: "0" + description: Fetch, SHA-256-verify, and install the pinned OpenCode CLI binary + command: + - sh + - -c + - | + OPENCODE_VERSION="v1.18.34" \ + OPENCODE_SHA256_LINUX_X64="0f22479647226d1d2dd99595d20082ee7bda3870b62dc6a90b41efc1a71d7e9a" \ + OPENCODE_SHA256_LINUX_ARM64="bbdb3f00c2c51e42e315525233151309724226a8776da8e9145e3b0fa3d5310f" \ + sh /home/agent/opencode-install.sh + +# No publishedPorts: opencode is a TUI, not a web UI (same as pi-coding-agent / +# prime-agent). Nothing to publish to the host. + +agentContext: | + ## OpenCode (terminal AI coding agent) + + This sandbox has **OpenCode** installed via a pinned, SHA-256-verified + release binary (not npm) — the kit's `opencode-install.sh` fetched the exact + pinned release tarball from GitHub Releases, verified its SHA-256, and + installed the binary at `/usr/local/bin/opencode` at sandbox creation. + + There is no startup supervisor and no published port: `opencode` is a plain + terminal command you run directly, the same as any other locally-installed + CLI. + +# No backend_shortcuts / backend_extras: this kit is written entirely in the +# neutral vocabulary (caps / files / commands / agent), which acq's kit +# translation feeds to whatever backend it has active (sbx, msb). From d49ea7360ae4d0bf6fd0947303aa4d1dd6d440c9 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 08:24:24 +0000 Subject: [PATCH 3/3] fix(acq-kits): harden opencode installer validation Address adversarial review findings for the opencode agent-harness kit. Make the offline verifier truly offline, reject unsafe temp-dir fallback, validate archive member type/count before extraction, avoid PATH in the final run check, and add kit troubleshooting documentation. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/README.md | 1 + .../isolation/acq-kits/opencode/README.md | 16 +- .../acq-kits/opencode/TROUBLESHOOTING.md | 114 ++++++++++ .../opencode/files/home/opencode-install.sh | 47 ++++- .../acq-kits/opencode/scripts/verify | 195 ++++++++++++++++-- .../isolation/acq-kits/opencode/spec.yaml | 5 +- 6 files changed, 345 insertions(+), 33 deletions(-) create mode 100644 integrations/isolation/acq-kits/opencode/TROUBLESHOOTING.md diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index 048f6765..82cde2e1 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -23,6 +23,7 @@ not agent behavior. (Behavioral patterns live in `skills/`, `prompts/`, etc.) | [`zscaler-ca-certificate/`](zscaler-ca-certificate/) | Trust the public Zscaler Root CA in the sandbox (msb: native `--trust-host-cas`; sbx: file-drop + `update-ca-certificates`). | | [`git-ssh-sign/`](git-ssh-sign/) | Sign git commits and tags with the SSH key forwarded from the host agent (vendored from sbx-kits-contrib). | | [`openchamber/`](openchamber/) | Run OpenChamber, a browser UI for OpenCode, inside the sandbox alongside the terminal TUI. Opt-in (see its parity note). | +| [`opencode/`](opencode/) | Install the OpenCode terminal AI coding agent as a pinned, SHA-256-verified release binary. Agent-harness kit; no ports. | | [`paseo/`](paseo/) | Self-host the [Paseo](https://github.com/getpaseo/paseo) daemon + browser web UI (for coding agents) inside the sandbox on one port. Opt-in; agent-generic wrapper, no shared TUI session. | | [`prime-agent/`](prime-agent/) | Install [PrimeIntellect-ai/prime-agent](https://github.com/PrimeIntellect-ai/prime-agent), a terminal AI coding assistant with an embedded IPython tool. Opt-in; TUI, no ports. | | [`pi-coding-agent/`](pi-coding-agent/) | Install [earendil-works/pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-coding-agent`), a plain terminal AI coding agent (read/write/edit/bash tools only). Opt-in; TUI, no ports. | diff --git a/integrations/isolation/acq-kits/opencode/README.md b/integrations/isolation/acq-kits/opencode/README.md index eb69e6b5..58599164 100644 --- a/integrations/isolation/acq-kits/opencode/README.md +++ b/integrations/isolation/acq-kits/opencode/README.md @@ -9,10 +9,10 @@ version-pinned install. This is the first **harness-agent kit** in the family: it declares the top-level `agent:` block (`name: opencode`, `entrypoint: opencode`) so acq's built-in agent-kit readiness gate can confirm this kit genuinely provides the -`opencode` agent token before enabling it for implicit agent selection — see -GSA-TTS/agentic-coding-quickstart's `acq.backends/common.sh` -(`acq_validate_agent_builtin_kit_dir`) and the ADR-0030 devenv/agent-kits -epic this kit is part of. +`opencode` agent token before enabling it for implicit agent selection. The +sibling quickstart repository implements that gate in `acq.backends/common.sh` +(`acq_validate_agent_builtin_kit_dir`). This kit is the reusable, declarative +replacement for acq's older backend-specific fallback installer. opencode is a **TUI, not a web UI** (contrast the sibling `paseo` and `openchamber` kits, which each publish a browser-UI port), so this kit @@ -111,10 +111,10 @@ revisited. ## Version pin `OPENCODE_VERSION` / `OPENCODE_SHA256_LINUX_X64` / `OPENCODE_SHA256_LINUX_ARM64` -default to a specific, reviewed release — never `latest`. Bump all three -together, as a deliberate reviewed change, after independently re-deriving -both hashes from the real release assets (download + `shasum -a 256`, not -copied from any third party). +are set by `spec.yaml` to a specific, reviewed release - never `latest`. Bump all +three together, as a deliberate reviewed change, after independently re-deriving +both hashes from the real release assets (download + `shasum -a 256`, not copied +from any third party). ## Backend parity diff --git a/integrations/isolation/acq-kits/opencode/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/opencode/TROUBLESHOOTING.md new file mode 100644 index 00000000..c0f8bb33 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/TROUBLESHOOTING.md @@ -0,0 +1,114 @@ +# Troubleshooting - opencode kit + +Failure modes specific to the opencode kit. They assume you applied the kit to a +sandbox (see [README.md](README.md)). + +## `opencode` is not installed + +**Symptoms:** `command -v opencode` returns nothing inside the sandbox, or acq's +agent-kit readiness gate reports that the `opencode` agent token is unavailable. + +**Cause:** this kit fails soft on availability problems so sandbox creation can +continue. The install script leaves `opencode` absent when it cannot safely fetch, +verify, extract, or install the pinned release binary. + +**Fix:** inspect the create-time install output and look for lines prefixed with +`opencode-kit(install):`. + +Common causes: + +- `OPENCODE_VERSION` or one of the architecture-specific SHA-256 pins was not set + by `spec.yaml`. +- The sandbox architecture is not `x86_64`, `amd64`, `aarch64`, or `arm64`. +- `curl` is missing from the base image. +- The sandbox cannot reach `github.com` or `release-assets.githubusercontent.com`. +- The release tarball could not be extracted or did not contain an executable + `opencode` member. +- `/usr/local/bin` could not be created or written by the root install step. + +Recreate the sandbox after fixing the underlying cause; the install step runs at +create time. + +## `SECURITY: sha256 mismatch` + +**Symptoms:** the install log contains `SECURITY: sha256 mismatch`, and +`opencode` is absent. + +**Cause:** the downloaded archive's SHA-256 did not match the pin in `spec.yaml`. +This is a fail-closed integrity refusal, not a transient availability degrade. +The script refuses to extract the mismatched archive and has no unpinned fallback. + +**Fix:** do not bypass the check. Confirm the intended OpenCode release, download +both Linux release assets from the official GitHub Release, re-derive their +SHA-256 values locally, update all three pins in `spec.yaml` together, and rerun +`scripts/verify` before recreating the sandbox. + +## `SECURITY: archive member 'opencode' is not a regular file` + +**Symptoms:** the install log contains `SECURITY: archive member 'opencode' is +not a regular file`, and `opencode` is absent. + +**Cause:** the verified archive's `opencode` entry is not an ordinary file. The +script rejects hardlinks, symlinks, device files, and other non-regular tar +members before extraction because installing one could trust or copy something +other than the release binary. + +**Fix:** treat this as a release-asset integrity problem. Do not install the +archive manually. Re-check the upstream release asset and update the kit only +after a human review confirms the expected archive layout. + +## `SECURITY: extracted 'opencode' member is a symlink` + +**Symptoms:** the install log contains `SECURITY: extracted 'opencode' member is +a symlink`, and `opencode` is absent. + +**Cause:** the verified archive passed the pre-extraction tar listing check but +extracted to a symlink on this platform. The script keeps this post-extraction +check as a second guard before installing into `/usr/local/bin`. + +**Fix:** treat this as a release-asset integrity problem. Do not install the +archive manually. Re-check the upstream release asset and update the kit only +after a human review confirms the expected archive layout. + +## The kit works on one host architecture but not another + +**Symptoms:** `opencode` installs on an x64 sandbox but not an arm64 sandbox, or +vice versa. + +**Cause:** the kit pins separate SHA-256 values for +`opencode-linux-x64.tar.gz` and `opencode-linux-arm64.tar.gz`. A missing or stale +pin for the current guest architecture makes the installer refuse to install an +unverified binary. + +**Fix:** update the version and both architecture hashes in `spec.yaml` together. +Then run the kit's verifier, preferably on both x64 and arm64-capable hosts when +available. + +## The verifier reports a schema skip + +**Symptoms:** `scripts/verify` prints `SKIP python3 with jsonschema+pyyaml not +available`. + +**Cause:** the offline schema check needs Python with `jsonschema` and `pyyaml`. +The skip does not mean the kit is invalid; it means the local host is missing the +validator dependencies. + +**Fix:** run the repository setup first, or install the validation dependencies in +your development environment, then rerun `scripts/verify`. + +## Live sandbox verification is skipped + +**Symptoms:** `scripts/verify` prints `RUN_ACQ=1 not set - skipped live acq +sandbox verification`. + +**Cause:** live sandbox creation is opt-in because it needs a working acq install +and sandbox-capable host. + +**Fix:** run the live check from a host where `acq` is installed and authenticated: + +```bash +RUN_ACQ=1 integrations/isolation/acq-kits/opencode/scripts/verify +``` + +Use `KEEP=1` only when you intentionally want to leave the temporary sandbox in +place for inspection. diff --git a/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh b/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh index 1f813722..bef3b960 100644 --- a/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh +++ b/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh @@ -158,8 +158,10 @@ if ! command -v curl >/dev/null 2>&1; then exit 0 fi -work_dir="$(mktemp -d "${TMPDIR:-/tmp}/opencode-install.XXXXXX" 2>/dev/null || echo "/tmp/opencode-install.$$")" -mkdir -p "$work_dir" 2>/dev/null || true +if ! work_dir="$(mktemp -d "${TMPDIR:-/tmp}/opencode-install.XXXXXX" 2>/dev/null)"; then + warn "could not create a private temporary directory; cannot download opencode safely. Skipping (non-fatal)." + exit 0 +fi cleanup() { rm -rf "$work_dir" 2>/dev/null || true; } trap cleanup EXIT @@ -197,6 +199,45 @@ if [ "$got_sha" != "$sha" ]; then fi echo "opencode-kit(install): sha256 verified for $asset ($ver)" +member_listing="$(tar -tvzf "$archive" opencode 2>"$work_dir/tar-list.log" || true)" +if [ -z "$member_listing" ]; then + warn "SECURITY: archive does not list an 'opencode' member;" + warn " refusing to install. This is a fail-closed integrity refusal, not a" + warn " transient-failure degrade — see docs/decisions/fail-closed-on-integrity.md." + [ -s "$work_dir/tar-list.log" ] && sed 's/^/opencode-kit(install): /' "$work_dir/tar-list.log" >&2 + exit 0 +fi +bad_member="" +member_count=0 +old_ifs="$IFS" +IFS=' +' +for member_line in $member_listing; do + member_count=$((member_count + 1)) + case "$member_line" in + -*) ;; + *) + bad_member="$member_line" + break + ;; + esac +done +IFS="$old_ifs" +if [ -n "$bad_member" ]; then + warn "SECURITY: archive member 'opencode' is not a regular file;" + warn " refusing to install. This is a fail-closed integrity refusal, not a" + warn " transient-failure degrade — see docs/decisions/fail-closed-on-integrity.md." + warn " tar listing: $bad_member" + [ -s "$work_dir/tar-list.log" ] && sed 's/^/opencode-kit(install): /' "$work_dir/tar-list.log" >&2 + exit 0 +fi +if [ "$member_count" -ne 1 ]; then + warn "SECURITY: archive lists $member_count 'opencode' members; expected exactly one;" + warn " refusing to install. This is a fail-closed integrity refusal, not a" + warn " transient-failure degrade — see docs/decisions/fail-closed-on-integrity.md." + exit 0 +fi + if ! tar -xzf "$archive" -C "$work_dir" opencode 2>"$work_dir/tar.log"; then warn "extraction failed (non-fatal; opencode will be absent this boot)" [ -s "$work_dir/tar.log" ] && sed 's/^/opencode-kit(install): /' "$work_dir/tar.log" >&2 @@ -236,7 +277,7 @@ if ! install -m 0755 "$work_dir/opencode" "$dest_dir/opencode" 2>"$work_dir/inst exit 0 fi -if command -v opencode >/dev/null 2>&1 && opencode --version >/dev/null 2>&1; then +if "$dest_dir/opencode" --version >/dev/null 2>&1; then echo "opencode-kit(install): opencode $ver ($oc_arch) installed and runnable at $dest_dir/opencode" else warn "opencode installed to $dest_dir but is not runnable (non-fatal; check base image compatibility)" diff --git a/integrations/isolation/acq-kits/opencode/scripts/verify b/integrations/isolation/acq-kits/opencode/scripts/verify index 58a3a62b..b8a6a69e 100755 --- a/integrations/isolation/acq-kits/opencode/scripts/verify +++ b/integrations/isolation/acq-kits/opencode/scripts/verify @@ -9,9 +9,11 @@ # + registry + the `agent:` block). Needs python3 with jsonschema + pyyaml; # if those aren't importable this check reports SKIP (with the fix), not a # false FAIL. Also offline: an INTEGRITY NEGATIVE test — runs the real -# install script against a deliberately-wrong pinned hash and asserts it -# refuses to install anything (see docs/decisions/fail-closed-on-integrity.md) -# rather than falling back to any unpinned path. +# install script against a local, deterministic tarball with a deliberately +# wrong pinned hash and asserts it refuses to install anything. It also +# checks that hash-matching archives with non-regular or duplicate +# `opencode` members are rejected before install (see +# docs/decisions/fail-closed-on-integrity.md). # # 2. LIVE END-TO-END (opt-in, RUN_ACQ=1): create a real sandbox with the kit # applied and assert the pinned opencode binary is present, on PATH, and @@ -56,8 +58,18 @@ cleanup() { trap cleanup EXIT INT TERM info "0. Preconditions" -[ -f "$KIT_DIR/spec.yaml" ] && ok "kit spec.yaml present" || { bad "spec.yaml missing"; exit 1; } -[ -x "$INSTALL_SCRIPT" ] || [ -f "$INSTALL_SCRIPT" ] && ok "install script present" || { bad "opencode-install.sh missing"; exit 1; } +if [ -f "$KIT_DIR/spec.yaml" ]; then + ok "kit spec.yaml present" +else + bad "spec.yaml missing" + exit 1 +fi +if [ -x "$INSTALL_SCRIPT" ] || [ -f "$INSTALL_SCRIPT" ]; then + ok "install script present" +else + bad "opencode-install.sh missing" + exit 1 +fi info "1. Offline schema + registry validation" if command -v python3 >/dev/null 2>&1 \ @@ -74,7 +86,7 @@ else fi info "2. Install script syntax" -if bash -n "$INSTALL_SCRIPT" 2>"$WORK/syntax.log"; then +if sh -n "$INSTALL_SCRIPT" 2>"$WORK/syntax.log"; then ok "opencode-install.sh parses as valid sh" else bad "opencode-install.sh has a syntax error" @@ -88,20 +100,47 @@ info "3. INTEGRITY NEGATIVE: a wrong pinned hash refuses to install (fail-closed # against a deliberately wrong hash, pointed at a scratch install dir, and # assert nothing landed there. NEG_DEST="$WORK/negative-dest" -mkdir -p "$NEG_DEST" +LAYOUT_DEST="$WORK/layout-dest" +DUP_DEST="$WORK/duplicate-dest" +DUP_REGULAR_DEST="$WORK/duplicate-regular-dest" +mkdir -p "$NEG_DEST" "$LAYOUT_DEST" "$DUP_DEST" "$DUP_REGULAR_DEST" NEG_SCRIPT="$WORK/opencode-install-negative.sh" -sed "s|dest_dir=\"/usr/local/bin\"|dest_dir=\"$NEG_DEST\"|" "$INSTALL_SCRIPT" > "$NEG_SCRIPT" -if command -v sh >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then +LAYOUT_SCRIPT="$WORK/opencode-install-layout.sh" +DUP_SCRIPT="$WORK/opencode-install-duplicate.sh" +DUP_REGULAR_SCRIPT="$WORK/opencode-install-duplicate-regular.sh" +STUB_BIN="$WORK/bin" +STUB_SRC="$WORK/stub-src" +mkdir -p "$STUB_BIN" "$STUB_SRC" +printf '%s\n' '#!/bin/sh' 'echo stub-opencode' > "$STUB_SRC/opencode" +chmod 0755 "$STUB_SRC/opencode" +cat > "$STUB_BIN/curl" <<'EOF' +#!/bin/sh +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + shift + out="${1:-}" + ;; + esac + shift || exit 2 +done +[ -n "$out" ] || exit 2 +cp "$STUB_OPENCODE_ARCHIVE" "$out" +EOF +chmod 0755 "$STUB_BIN/curl" +if tar -czf "$WORK/stub-opencode.tar.gz" -C "$STUB_SRC" opencode 2>"$WORK/stub-tar.log"; then + sed "s|dest_dir=\"/usr/local/bin\"|dest_dir=\"$NEG_DEST\"|" "$INSTALL_SCRIPT" > "$NEG_SCRIPT" # Isolate PATH for this probe: the HOST may itself have an `opencode` binary # on PATH (e.g. a developer's own install) which would trip the script's own - # idempotency short-circuit ("already installed and runnable; skipping") - # before ever reaching the download/verify path this test exists to exercise. - # The real sandbox target never has that collision; this is a host-only test - # artifact, isolated here rather than weakening the script under test. + # idempotency short-circuit before ever reaching the download/verify path this + # test exists to exercise. The local curl stub keeps the gate offline while + # still running the real installer download/hash/extract control flow. if OPENCODE_VERSION="v1.18.34" \ OPENCODE_SHA256_LINUX_X64="deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" \ OPENCODE_SHA256_LINUX_ARM64="deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" \ - PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + STUB_OPENCODE_ARCHIVE="$WORK/stub-opencode.tar.gz" \ + PATH="$STUB_BIN:/usr/bin:/bin:/usr/sbin:/sbin" \ sh "$NEG_SCRIPT" >"$WORK/negative.log" 2>&1; then if [ -z "$(find "$NEG_DEST" -mindepth 1 2>/dev/null)" ]; then ok "wrong-hash run refused to install anything (dest dir stayed empty)" @@ -119,25 +158,141 @@ if command -v sh >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then sed 's/^/ | /' "$WORK/negative.log" >&2 fi else - skip "sh/curl not available for the offline integrity negative test" + skip "tar not available for the offline integrity negative test" + sed 's/^/ | /' "$WORK/stub-tar.log" >&2 +fi + +info "4. ARCHIVE LAYOUT NEGATIVE: a hardlink member refuses to install (fail-closed)" +HARDLINK_SRC="$WORK/hardlink-src" +mkdir -p "$HARDLINK_SRC" +printf '%s\n' '#!/bin/sh' 'echo hardlink-target' > "$HARDLINK_SRC/target" +chmod 0755 "$HARDLINK_SRC/target" +ln "$HARDLINK_SRC/target" "$HARDLINK_SRC/opencode" +if tar -czf "$WORK/hardlink-opencode.tar.gz" -C "$HARDLINK_SRC" target opencode 2>"$WORK/hardlink-tar.log"; then + hardlink_sha="$( (sha256sum "$WORK/hardlink-opencode.tar.gz" 2>/dev/null || shasum -a 256 "$WORK/hardlink-opencode.tar.gz" 2>/dev/null) | cut -d' ' -f1)" + sed "s|dest_dir=\"/usr/local/bin\"|dest_dir=\"$LAYOUT_DEST\"|" "$INSTALL_SCRIPT" > "$LAYOUT_SCRIPT" + if OPENCODE_VERSION="v1.18.34" \ + OPENCODE_SHA256_LINUX_X64="$hardlink_sha" \ + OPENCODE_SHA256_LINUX_ARM64="$hardlink_sha" \ + STUB_OPENCODE_ARCHIVE="$WORK/hardlink-opencode.tar.gz" \ + PATH="$STUB_BIN:/usr/bin:/bin:/usr/sbin:/sbin" \ + sh "$LAYOUT_SCRIPT" >"$WORK/layout.log" 2>&1; then + if [ -z "$(find "$LAYOUT_DEST" -mindepth 1 2>/dev/null)" ]; then + ok "hardlink-member archive refused to install anything (dest dir stayed empty)" + else + bad "hardlink-member archive INSTALLED something despite non-regular member" + sed 's/^/ | /' "$WORK/layout.log" >&2 + fi + if grep -q "SECURITY: archive member 'opencode' is not a regular file" "$WORK/layout.log"; then + ok "hardlink member produced the expected SECURITY:-prefixed refusal message" + else + bad "hardlink member did not log the expected SECURITY:-prefixed refusal" + sed 's/^/ | /' "$WORK/layout.log" >&2 + fi + else + bad "hardlink-member run exited non-zero (should exit 0 — fail-closed is still a soft sandbox-level exit, see ADR)" + sed 's/^/ | /' "$WORK/layout.log" >&2 + fi +else + skip "tar hardlink fixture could not be created for the archive-layout negative test" + sed 's/^/ | /' "$WORK/hardlink-tar.log" >&2 +fi + +info "5. ARCHIVE LAYOUT NEGATIVE: duplicate opencode entries refuse to install (fail-closed)" +DUP_SRC_REGULAR="$WORK/duplicate-regular" +DUP_SRC_HARDLINK="$WORK/duplicate-hardlink" +mkdir -p "$DUP_SRC_REGULAR" "$DUP_SRC_HARDLINK" +printf '%s\n' '#!/bin/sh' 'echo duplicate-regular' > "$DUP_SRC_REGULAR/opencode" +printf '%s\n' '#!/bin/sh' 'echo duplicate-target' > "$DUP_SRC_HARDLINK/target" +chmod 0755 "$DUP_SRC_REGULAR/opencode" "$DUP_SRC_HARDLINK/target" +ln "$DUP_SRC_HARDLINK/target" "$DUP_SRC_HARDLINK/opencode" +if tar -cf "$WORK/duplicate-opencode.tar" -C "$DUP_SRC_REGULAR" opencode 2>"$WORK/duplicate-tar.log" \ + && tar -rf "$WORK/duplicate-opencode.tar" -C "$DUP_SRC_HARDLINK" target opencode 2>>"$WORK/duplicate-tar.log" \ + && gzip -c "$WORK/duplicate-opencode.tar" > "$WORK/duplicate-opencode.tar.gz"; then + duplicate_sha="$( (sha256sum "$WORK/duplicate-opencode.tar.gz" 2>/dev/null || shasum -a 256 "$WORK/duplicate-opencode.tar.gz" 2>/dev/null) | cut -d' ' -f1)" + sed "s|dest_dir=\"/usr/local/bin\"|dest_dir=\"$DUP_DEST\"|" "$INSTALL_SCRIPT" > "$DUP_SCRIPT" + if OPENCODE_VERSION="v1.18.34" \ + OPENCODE_SHA256_LINUX_X64="$duplicate_sha" \ + OPENCODE_SHA256_LINUX_ARM64="$duplicate_sha" \ + STUB_OPENCODE_ARCHIVE="$WORK/duplicate-opencode.tar.gz" \ + PATH="$STUB_BIN:/usr/bin:/bin:/usr/sbin:/sbin" \ + sh "$DUP_SCRIPT" >"$WORK/duplicate.log" 2>&1; then + if [ -z "$(find "$DUP_DEST" -mindepth 1 2>/dev/null)" ]; then + ok "duplicate-member archive refused to install anything (dest dir stayed empty)" + else + bad "duplicate-member archive INSTALLED something despite a later non-regular member" + sed 's/^/ | /' "$WORK/duplicate.log" >&2 + fi + if grep -q "SECURITY: archive member 'opencode' is not a regular file" "$WORK/duplicate.log"; then + ok "duplicate member produced the expected SECURITY:-prefixed refusal message" + else + bad "duplicate member did not log the expected SECURITY:-prefixed refusal" + sed 's/^/ | /' "$WORK/duplicate.log" >&2 + fi + else + bad "duplicate-member run exited non-zero (should exit 0 — fail-closed is still a soft sandbox-level exit, see ADR)" + sed 's/^/ | /' "$WORK/duplicate.log" >&2 + fi +else + skip "tar duplicate-member fixture could not be created for the archive-layout negative test" + sed 's/^/ | /' "$WORK/duplicate-tar.log" >&2 +fi + +info "6. ARCHIVE LAYOUT NEGATIVE: duplicate regular opencode entries refuse to install (fail-closed)" +DUP_REGULAR_A="$WORK/duplicate-regular-a" +DUP_REGULAR_B="$WORK/duplicate-regular-b" +mkdir -p "$DUP_REGULAR_A" "$DUP_REGULAR_B" +printf '%s\n' '#!/bin/sh' 'echo duplicate-regular-a' > "$DUP_REGULAR_A/opencode" +printf '%s\n' '#!/bin/sh' 'echo duplicate-regular-b' > "$DUP_REGULAR_B/opencode" +chmod 0755 "$DUP_REGULAR_A/opencode" "$DUP_REGULAR_B/opencode" +if tar -cf "$WORK/duplicate-regular-opencode.tar" -C "$DUP_REGULAR_A" opencode 2>"$WORK/duplicate-regular-tar.log" \ + && tar -rf "$WORK/duplicate-regular-opencode.tar" -C "$DUP_REGULAR_B" opencode 2>>"$WORK/duplicate-regular-tar.log" \ + && gzip -c "$WORK/duplicate-regular-opencode.tar" > "$WORK/duplicate-regular-opencode.tar.gz"; then + duplicate_regular_sha="$( (sha256sum "$WORK/duplicate-regular-opencode.tar.gz" 2>/dev/null || shasum -a 256 "$WORK/duplicate-regular-opencode.tar.gz" 2>/dev/null) | cut -d' ' -f1)" + sed "s|dest_dir=\"/usr/local/bin\"|dest_dir=\"$DUP_REGULAR_DEST\"|" "$INSTALL_SCRIPT" > "$DUP_REGULAR_SCRIPT" + if OPENCODE_VERSION="v1.18.34" \ + OPENCODE_SHA256_LINUX_X64="$duplicate_regular_sha" \ + OPENCODE_SHA256_LINUX_ARM64="$duplicate_regular_sha" \ + STUB_OPENCODE_ARCHIVE="$WORK/duplicate-regular-opencode.tar.gz" \ + PATH="$STUB_BIN:/usr/bin:/bin:/usr/sbin:/sbin" \ + sh "$DUP_REGULAR_SCRIPT" >"$WORK/duplicate-regular.log" 2>&1; then + if [ -z "$(find "$DUP_REGULAR_DEST" -mindepth 1 2>/dev/null)" ]; then + ok "duplicate-regular-member archive refused to install anything (dest dir stayed empty)" + else + bad "duplicate-regular-member archive INSTALLED something despite duplicate regular members" + sed 's/^/ | /' "$WORK/duplicate-regular.log" >&2 + fi + if grep -q "SECURITY: archive lists 2 'opencode' members" "$WORK/duplicate-regular.log"; then + ok "duplicate regular members produced the expected SECURITY:-prefixed refusal message" + else + bad "duplicate regular members did not log the expected SECURITY:-prefixed refusal" + sed 's/^/ | /' "$WORK/duplicate-regular.log" >&2 + fi + else + bad "duplicate-regular-member run exited non-zero (should exit 0 — fail-closed is still a soft sandbox-level exit, see ADR)" + sed 's/^/ | /' "$WORK/duplicate-regular.log" >&2 + fi +else + skip "tar duplicate-regular-member fixture could not be created for the archive-layout negative test" + sed 's/^/ | /' "$WORK/duplicate-regular-tar.log" >&2 fi if [ "$RUN_ACQ" != "1" ]; then - info "4. Live sandbox check" + info "7. Live sandbox check" skip "RUN_ACQ=1 not set — skipped live acq sandbox verification" printf '\nSummary\n %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] exit $? fi -info "4. Live sandbox preconditions" +info "7. Live sandbox preconditions" if ! command -v acq >/dev/null 2>&1; then bad "acq not on PATH" exit 1 fi ok "acq on PATH" -info "5. Create sandbox with the kit" +info "8. Create sandbox with the kit" printf ' creating %s (workspace %s)...\n' "$SBX_NAME" "$WORK" if acq create --name "$SBX_NAME" --kit "$KIT_DIR" opencode "$WORK" >"$CREATE_LOG" 2>&1; then ok "sandbox created with --kit opencode" @@ -149,7 +304,7 @@ fi in_sbx() { acq exec "$SBX_NAME" -- sh -c "$1" /dev/null || true; } -info "6. opencode binary present and on PATH" +info "9. opencode binary present and on PATH" _elapsed=0 while [ "$_elapsed" -lt "$READY_TIMEOUT" ]; do if [ "$(in_sbx "command -v opencode >/dev/null 2>&1 && echo yes")" = "yes" ]; then @@ -164,7 +319,7 @@ else bad "opencode not found on PATH after ${READY_TIMEOUT}s" fi -info "7. Installed version matches the pin" +info "10. Installed version matches the pin" installed_ver="$(in_sbx "opencode --version 2>/dev/null")" pinned_ver="$(grep -o 'OPENCODE_VERSION="v[0-9.]*"' "$KIT_DIR/spec.yaml" | head -n1 | sed -E 's/.*="v([0-9.]*)"/\1/')" echo " installed: ${installed_ver:-} pinned: ${pinned_ver:-}" diff --git a/integrations/isolation/acq-kits/opencode/spec.yaml b/integrations/isolation/acq-kits/opencode/spec.yaml index ecd702d0..9de213e2 100644 --- a/integrations/isolation/acq-kits/opencode/spec.yaml +++ b/integrations/isolation/acq-kits/opencode/spec.yaml @@ -8,8 +8,9 @@ # `opencode` agent token so acq's built-in agent-kit readiness gate # (acq_validate_agent_builtin_kit_dir / kit_spec_agent_field, in the sibling # quickstart repo's acq.backends/common.sh) can confirm this kit genuinely -# matches the agent it's about to enable, before enabling it. Part of the -# devenv/agent-kits ADR-0030 line of work in that repo. +# matches the agent it's about to enable, before enabling it. This is the +# reusable, declarative replacement for acq's older backend-specific fallback +# installer. # # WHY THIS KIT EXISTS (not just "nice to have a pin"): the current MSB install # path has two independent problems this kit closes together: (1) no version