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/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index bab6eb7e..2c68e6c2 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..58599164 --- /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. 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 +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` +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 + +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/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/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..bef3b960 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/files/home/opencode-install.sh @@ -0,0 +1,284 @@ +#!/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 + +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 + +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)" + +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 + 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 "$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)" +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..b8a6a69e --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/scripts/verify @@ -0,0 +1,336 @@ +#!/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 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 +# 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" +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 \ + && 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 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" + 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" +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" +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 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" \ + 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)" + 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 "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 "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 "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 "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" +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 "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 + 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 "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:-}" +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..9de213e2 --- /dev/null +++ b/integrations/isolation/acq-kits/opencode/spec.yaml @@ -0,0 +1,133 @@ +# 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. 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 +# 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).