diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index 6dd74ee6..048f6765 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -24,6 +24,8 @@ not agent behavior. (Behavioral patterns live in `skills/`, `prompts/`, etc.) | [`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). | | [`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. | Each kit is self-contained: a `spec.yaml` (`hybrid/v1`), any `files/` payload, a `scripts/verify` host-side check, a `README.md` (with a **backend parity** note), diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 2805e347..992ad0c2 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -91,8 +91,29 @@ kits: (files/home/prime-agent-install-models-json.sh) and bootstrap the IPython kernel (files/home/prime-agent-bootstrap-kernel.sh). Publishes NO ports (prime-agent is a TUI, not a web UI) — unlike openchamber/paseo. SKELETON - only (#369): the three scripts are placeholders; the real install/CA-bundle/ - kernel logic lands in #370-#373, and the model catalog - (files/home/prime-agent-config/models.json) is generated by the USAi catalog - emitter (#362). Model-provider egress is the usai-provider kit's job, not - this one. + only: the three scripts are placeholders; the real install/CA-bundle/ + kernel logic and the generated model catalog + (files/home/prime-agent-config/models.json) land in follow-up work tracked + in the patterns repo's prime-agent epic. Model-provider egress is the + usai-provider kit's job, not this one. + + pi-coding-agent: + backends: [sbx, msb] + parity: | + Opt-in mixin that installs earendil-works/pi (MIT), npm package + @earendil-works/pi-coding-agent — a plain terminal AI coding agent that + grants the model only read/write/edit/bash tools by default (no built-in + MCP, sub-agents, permission popups, or plan mode). Written entirely in the + neutral vocabulary with no backend shortcut or extras: a single + startup-phase script (files/home/pi-coding-agent-install.sh) installs the + CLI via the standard npm registry path (npm install -g --ignore-scripts, + pinned version, unprivileged per-user npm prefix, no pi.dev egress). + Publishes NO ports (a TUI, not a web UI) — like prime-agent, unlike + openchamber/paseo. No install phase — unlike prime-agent's still-in-flight + skeleton, this kit is startup-only throughout, matching openchamber's + settled pattern; shipped ahead of prime-agent to validate that pattern on + a materially simpler target with no native-module or Python-kernel + bootstrap step. Model-provider config (USAi models.json) is deliberately + deferred — no emitter exists yet to populate it — and will land in a + follow-up alongside a real pi USAi-catalog emitter. Model-provider egress + is the usai-provider kit's job, not this one. diff --git a/integrations/isolation/acq-kits/pi-coding-agent/README.md b/integrations/isolation/acq-kits/pi-coding-agent/README.md new file mode 100644 index 00000000..b60f6da5 --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/README.md @@ -0,0 +1,131 @@ +# pi-coding-agent (acq mixin kit) + +Installs [earendil-works/pi](https://github.com/earendil-works/pi) (MIT), +npm package [`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) +— a plain terminal AI coding agent that grants the model only +`read`/`write`/`edit`/`bash` tools by default. Opt-in mixin; composes with the +`usai-provider` kit and the other GSA kits. + +pi is a **TUI, not a web UI**, so this kit publishes **no ports** (contrast +the sibling `paseo` and `openchamber` kits, which each publish a browser UI +port), and — unlike the sibling `prime-agent` kit — needs **no embedded +Python/IPython kernel**, so this is the simplest kit in the family. + +## Sequencing note + +This kit ships **ahead of** the in-flight `prime-agent` kit +(GSA-TTS/agentic-coding-patterns#368) by design: a completed consensus vote +picked pi specifically to validate the unprivileged/agent-owned/startup-only +install pattern on a materially simpler target before returning to +prime-agent's heavier kernel-bootstrap complexity. prime-agent's own epic +remains open, not abandoned. + +## Status + +`spec.yaml` and the install script are complete for **CLI install only**. +Model-provider configuration (a USAi `models.json` for pi) is **deliberately +deferred**, not merely out of scope: an earlier draft shipped a hand-written +placeholder catalog with an empty `models` array and a script to install it, +and review found that pairing provides zero functional value with no USAi +catalog **pi emitter** yet to populate it (contrast prime-agent's own +placeholder, which ships a real, populated model list generated by an +existing emitter). That step was cut rather than shipped as functionless +scaffolding, and will land together with a real `pi.mjs` emitter in a +follow-up. + +Until then, authenticate `pi` manually inside the sandbox — `/login`, an +environment variable, or `~/.pi/agent/auth.json` per +[pi's own provider docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/providers.md). + +## What it does + +- **Startup phase only** (every start, agent user, uid `1000`) — no install + phase. `pi-coding-agent-install.sh` installs the `pi` CLI via + `npm install -g --ignore-scripts`, pinned to a specific reviewed version, + into an unprivileged per-user npm prefix (`$HOME/.local` — see + [`docs/decisions/local-prefix-not-npm-global.md`](docs/decisions/local-prefix-not-npm-global.md) + for why not the `$HOME/.npm-global` convention the sibling kits use). + Idempotent: a no-op if `pi` is already on `PATH`. Rebuilds the CA trust + bundle and the `pi` wrapper (see + [`docs/decisions/ca-bundle-wrapper-not-env-var.md`](docs/decisions/ca-bundle-wrapper-not-env-var.md)) + on **every** boot, not just a fresh install. + +There is no supervisor loop and nothing running in the background — `pi` is a +TUI the agent runs directly; this kit does not manage a shared session the +way the `openchamber`/`paseo` web-UI kits do. + +## Install method: npm registry, not `pi.dev/install.sh` + +pi.dev publishes a curl-pipe-to-shell installer, but this kit does **not** +fetch it (a mutable live script with an interactive Node/npm preflight path, +and `pi.dev` egress this kit's allow-list deliberately omits — see the +install script's own header comment for the full rationale). The short +version: `npm install -g @earendil-works/pi-coding-agent` needs only +`registry.npmjs.org`. + +**Open question, not yet verified:** does the installed `pi` binary itself +phone home to `pi.dev` at runtime (update checks, telemetry)? If a future +live trace of a running `pi` session finds that it does, add the host then +with a live-tested justification — it is not allow-listed speculatively now. + +## Egress allow-list + +Deny-by-default. The allow-list in `spec.yaml` is pi's own install host only: + +- `registry.npmjs.org` — npm package metadata and tarballs (no wildcard + subdomain needed) + +Deliberately **absent** (see "Install method" above for why): `pi.dev`, +`github.com` / `*.githubusercontent.com`. + +Model-provider egress (`api.gsa.usai.gov`) is **not** here — that is the +`usai-provider` kit's job. + +## Supply-chain notes + +- **Version pin**: `PI_CODING_AGENT_VERSION` defaults to a specific, reviewed + npm release rather than `latest` — this kit's behavior must not silently + drift to whatever npm resolves as newest on every sandbox boot. Bump the + pin deliberately, as its own reviewed change. +- **Node version is checked explicitly**: pi requires Node.js >=22.19.0 (its + own published npm registry metadata's `engines.node`). npm's own + `engine-strict` defaults to `false`, so a plain `npm install` would + otherwise proceed silently on an older Node and only fail later, opaquely, + the first time `pi` itself runs. This kit checks the version explicitly + before installing and fails clearly if it's too old — mirroring pi's own + installer's preflight check. +- **Registry fetch is bounded**: `npm install` runs with `--fetch-timeout` and + `--fetch-retries=0` — verified live that against an unreachable or + silently-dropping registry, npm's default retry/backoff behavior can hang + well past a minute, which would delay sandbox startup. Retries are set to 0 + deliberately: this is a startup-phase script that runs on every boot, not a + one-shot manual install, so the *next* boot already retries naturally. +- **`--ignore-scripts` is not a complete mitigation.** It blocks npm + lifecycle hooks at install time, not code that runs when `pi` is later + **invoked** (see the install script's own comment on this exact flag) — + the ephemeral, single-tenant sandbox remains the real containment + boundary. +- **CA-bundle handling is load-bearing, not defensive** — see + [`docs/decisions/ca-bundle-wrapper-not-env-var.md`](docs/decisions/ca-bundle-wrapper-not-env-var.md) + for the full design and why a runtime wrapper, not an install-time + `export`, is required for the bundle to actually reach `pi`'s own later + invocations. + +## Naming + +The kit is named `pi-coding-agent` — matching the npm package name — rather +than the bare upstream shorthand `pi`. A bare `pi` kit name would collide +conceptually with the ubiquitous mathematical constant/variable name in every +log line, script, and error message this kit produces, a real ambiguity risk +unique to this product's name that none of the sibling kits' +(`openchamber`/`paseo`/`prime-agent`/`zscaler-ca-certificate`) share. Every +file, script, and log line in this kit says `pi-coding-agent`, never bare +`pi`, for the same reason. + +## Backend parity + +Written entirely in the neutral hybrid/v1 vocabulary (caps / files / +commands), 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/pi-coding-agent/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md new file mode 100644 index 00000000..a903d769 --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md @@ -0,0 +1,155 @@ +# Troubleshooting — pi-coding-agent kit + +Failure modes specific to the pi-coding-agent kit, which installs +[earendil-works/pi](https://github.com/earendil-works/pi) +(`@earendil-works/pi-coding-agent`) via the npm registry at every sandbox +start. Most diagnosis is done with `acq exec -- sh -c '…'`. Unlike +the sibling `openchamber`/`paseo` kits, a bare `acq exec` here does **not** +need any `PATH=` prepend to find `pi` — the install prefix is `$HOME/.local`, +already first on the guest's default `PATH` (see +[`docs/decisions/local-prefix-not-npm-global.md`](docs/decisions/local-prefix-not-npm-global.md)). + +## Node.js and/or npm not found + +**Symptoms:** the startup log shows +`pi-coding-agent: node and/or npm not found on PATH; cannot install pi this +boot (installing Node is out of scope for this kit — see spec.yaml)`, and `pi` +never appears. + +**Cause.** This kit deliberately does not install Node — it assumes the base +image already carries it (the same assumption `openchamber` makes for its own +base image). A customized or minimal base image may not. + +**Fix:** use the `opencode` base template (the default, which ships Node), or +add a Node install step ahead of this kit in your kit composition. + +## Node.js too old + +**Symptoms:** the startup log shows +`pi-coding-agent: pi requires Node.js >=22.19.0, found vX.Y.Z; cannot install +pi this boot (installing/upgrading Node is out of scope for this kit)`. + +**Cause.** pi's own published npm registry metadata (`engines.node`) requires +Node `>=22.19.0`. npm's `engine-strict` defaults to `false`, so a plain +`npm install` would otherwise proceed silently on an older Node and only fail +later, opaquely, the first time `pi` itself runs — this kit checks explicitly +up front instead, mirroring pi's own installer's preflight check. + +**Fix:** upgrade the base image's Node to `>=22.19.0`, or pair this kit with a +Node-upgrade step. Confirm the guest's version: + +```bash +acq exec -- sh -c 'node --version' +``` + +## Malformed `PI_CODING_AGENT_VERSION` — install refused + +**Symptoms:** the startup log shows +`pi-coding-agent: PI_CODING_AGENT_VERSION='' doesn't look like a +version; refusing to install` (or `... contains characters outside +[0-9A-Za-z.-]; refusing to install`), and `pi` is never installed. + +**Cause.** The kit validates the version pin's shape before it ever reaches +the npm package-spec string (`@earendil-works/pi-coding-agent@`). +This fails **closed**: an unexpected value (typo, stray whitespace, an +accidentally-injected flag-looking token) is refused rather than silently +passed through. Note that this is a defensive **format** check, not a +security boundary on its own — the value is always passed as a single quoted +argv word, never re-parsed by a shell, so it was never shell-injectable; the +check exists so a malformed pin fails loudly and immediately instead of +producing a confusing downstream npm error. + +**Fix:** set `PI_CODING_AGENT_VERSION` to either `latest` or a plain +`MAJOR.MINOR.PATCH`-shaped string (matching `[0-9A-Za-z.-]` only) in the +kit's `environment:`/spec configuration, then recreate the sandbox (or +restart it so the startup step re-runs). + +## npm install failure or timeout + +**Symptoms:** the startup log shows +`pi-coding-agent: npm install exited (prefix=...); pi unavailable this +boot. See `, and `pi` is not on `PATH`. + +**Cause.** Most commonly: egress to `registry.npmjs.org` is blocked (this +kit's only allow-listed host), a proxy/TLS issue (see the next entry), or a +slow/silently-dropping connection. The install runs with +`--fetch-timeout=30000 --fetch-retries=0` deliberately — this is a +**startup-phase** script that runs on every boot, not a one-shot manual +install, so retrying against a systemic failure just delays the sandbox for +no benefit; the *next* boot retries naturally. + +**Fix:** check the install log and egress: + +```bash +acq exec -- sh -c 'cat ~/.local/state/pi-coding-agent/install.log 2>/dev/null | tail -n 40' +acq exec -- sh -c 'echo "${HTTPS_PROXY:-unset}"' +``` + +If `registry.npmjs.org` is not reachable under org network policy, confirm it +is on the sandbox's effective allow-list (`sbx policy log ` / +`acq policy log ` under org governance may show it superseded). + +## CA-decode failure (malformed `PROXY_CA_CERT_B64`) + +**Symptoms:** the startup log shows +`pi-coding-agent: PROXY_CA_CERT_B64 failed to base64-decode; proxy CA NOT +added to the bundle (see )`, typically followed by an npm/TLS +failure like `unable to get local issuer certificate` or +`SELF_SIGNED_CERT_IN_CHAIN` on an inspected network. + +**Cause.** `PROXY_CA_CERT_B64` (base64-encoded PEM, supplied by whatever +injects a proxy CA into the sandbox — e.g. the `zscaler-ca-certificate` kit) +failed to decode. Unlike the sibling `openchamber` kit's equivalent block +(which swallows this same decode failure via `2>/dev/null`), this kit's +install script surfaces it explicitly, on the theory that a silently +incomplete CA bundle produces a much more confusing downstream TLS error than +telling you up front that the base64 itself was bad. + +**Fix:** confirm the value is actually valid base64-encoded PEM from the +source that's supposed to provide it (commonly the `zscaler-ca-certificate` +kit — pair it if you're on an inspected network), then recreate/restart the +sandbox: + +```bash +acq exec -- sh -c 'echo "PROXY_CA_CERT_B64 len=${#PROXY_CA_CERT_B64}"' +acq exec -- sh -c 'echo "$PROXY_CA_CERT_B64" | base64 -d | head -c 40' # should start with -----BEGIN CERTIFICATE----- +``` + +## pi installs but making an LLM-provider call fails TLS + +**Symptoms:** `pi` runs, but any call it makes to a model provider on an +inspected network fails with a TLS trust error (`unable to get local issuer +certificate`, `SELF_SIGNED_CERT_IN_CHAIN`). + +**Cause.** `NODE_EXTRA_CA_CERTS` only **appends** to Node's built-in trust +roots — it needs the sandbox's proxy CA. Per the wrapper design in +`docs/decisions/`, the CA bundle is **rebuilt once per boot** by the startup +script, and the wrapper at `$HOME/.local/bin/pi` simply `exec`s the real `pi` +binary with that boot's already-current bundle path — it does not rebuild +anything itself at invocation. A plain `export` inside the install script's +own process would not persist to a separately-launched `pi` process later, +which is exactly why the wrapper exists. + +**Fix:** confirm the wrapper still references `NODE_EXTRA_CA_CERTS` and +`exec`s the real binary (this is the normal, expected shape — not a sign of +breakage): + +```bash +acq exec -- sh -c 'cat ~/.local/bin/pi' # should show a small script that sets NODE_EXTRA_CA_CERTS and execs the real pi binary +``` + +If the wrapper is missing `NODE_EXTRA_CA_CERTS` entirely (not just a static +one-time export baked in at some earlier boot, but absent altogether), it has +regressed; see `docs/decisions/` for the intended design and file an issue. + +## No model provider configured + +**Symptoms:** `pi` runs but has no LLM provider to talk to. + +**Cause.** This kit deliberately defers model-provider configuration (no +USAi `models.json` catalog exists for pi yet — see the README's "Status" +section). + +**Fix:** authenticate `pi` manually inside the sandbox — `/login`, a provider +environment variable, or `~/.pi/agent/auth.json` — per +[pi's own provider docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/providers.md). diff --git a/integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/ca-bundle-wrapper-not-env-var.md b/integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/ca-bundle-wrapper-not-env-var.md new file mode 100644 index 00000000..d6b24068 --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/ca-bundle-wrapper-not-env-var.md @@ -0,0 +1,130 @@ +# Decision: a runtime CA-bundle wrapper for `pi`, not an install-time env var + +**Status:** accepted + +## Context + +`pi` is a plain npm-installed Node CLI. It makes HTTPS requests in two +distinct lifetimes: + +1. **At install time** — `npm install` itself, fetching package metadata and + tarballs from `registry.npmjs.org`. +2. **At runtime, later, in a completely separate process** — every time the + agent user actually invokes `pi` and it talks to whichever LLM provider + API it is configured against. + +`NODE_EXTRA_CA_CERTS` only **appends** to Node's built-in trust roots; it does +not replace them. On an HTTPS-inspecting network (e.g. Zscaler) neither the +sandbox proxy's CA nor an inspection CA is in Node's built-in root set, so +without `NODE_EXTRA_CA_CERTS` pointed at a bundle that includes it, Node fails +closed with `SELF_SIGNED_CERT_IN_CHAIN` / `unable to get local issuer +certificate`. `pi-coding-agent-install.sh` already builds exactly this bundle +(proxy CA from `PROXY_CA_CERT_B64`, base64-decoded, concatenated with the +system trust store) for lifetime (1) — this part is a straight adaptation of +`openchamber`'s own script, with one deliberate improvement (a base64-decode +failure is surfaced, not swallowed — see the install script's own comment). + +The problem is lifetime (2). `export NODE_EXTRA_CA_CERTS=...` inside the +install script only sets that variable for the install script's **own +process** (and its children, for as long as it's running). It does **not** +persist to a `pi` process launched **later**, by a **different** parent (a +fresh `acq exec`, a fresh `acq run` shell, the human's own interactive +session) — environment variables set by a shell script do not outlive that +script once it exits, and nothing else in this kit's design re-exports them +into every later session's environment. A plain `npm install -g` bin-link at +`~/.local/bin/pi` would therefore run with **no** `NODE_EXTRA_CA_CERTS` at +all in every real invocation, defeating the entire point of building the +bundle in the first place — it would only ever have been correct during the +one install-time process that happened to set it. + +There is also a second reason the bundle can't just be computed once and +written to a static file referenced by a persisted env var: the sandbox's +proxy CA (`PROXY_CA_CERT_B64`) and the system trust store are each supplied +per-boot by whatever injects them (the sbx/msb backend, or a paired kit like +`zscaler-ca-certificate`) — a bundle baked in at install time and never +refreshed could go stale across a container restart if either input changes, +even though the install step itself only runs once (guarded by `command -v +pi`). + +## Decision + +**Ship `pi` behind a thin wrapper at `$HOME/.local/bin/pi`** (the same +default-PATH location chosen in +[`local-prefix-not-npm-global.md`](local-prefix-not-npm-global.md)) that: + +1. Points at a CA bundle the install script rebuilds fresh **on every boot** + (same proxy-CA-decode + system-trust-store-append logic described above), + from whatever `PROXY_CA_CERT_B64` / system trust store are present in + *that* boot's environment. The wrapper itself does not re-derive + anything at invocation time — it references the bundle PATH the + startup step (which runs unconditionally on every boot, not just on a + fresh install) just built. Any `pi` invocation within that boot gets + that boot's current bundle; the bundle is only as stale as the sandbox's + own uptime since its last boot, never staler than an install-time-only + `export` would leave it (which is stale from the very next process + onward, not just the next boot). +2. `exec`s the real, npm-installed `pi` binary (resolved from the actual npm + global-prefix `bin/`, not itself) with the CA env var set for that exec, + passing all arguments through unchanged. + +This mirrors the same "wrapper `exec`s the real binary, doesn't just set an +env var and hope it survives" shape the sibling `openchamber` kit uses for its +own `opencode` wrapper (see +`../../openchamber/docs/decisions/wrapper-entrypoint-owns-server.md`) and the +`paseo` kit uses for its own agent shim +(`../../paseo/docs/decisions/paseo-mixin-generic-wrapper.md`) — a wrapper is +this family's established pattern for "do something at invocation time, then +become the real thing," not a one-off invention for this kit. + +## Why not simpler alternatives + +- **`export NODE_EXTRA_CA_CERTS=...` in `~/.profile`/`~/.bashrc`.** Would only + take effect for a **login** shell that sources it. `acq exec`/`sbx exec` + run a bare non-login `sh -c` (see the sibling kits' own `scripts/verify` + comments on this exact point) — a profile-sourced export is invisible to + exactly the invocation path this kit most needs to support. +- **A backend-level persisted env var (`environment:` in `spec.yaml`).** This + kit's `environment:` block sets *names* the backend maps onto its native + env mechanism at container start — but the **value** here is not static + per-boot; it must be derived (decode + concatenate) from + `PROXY_CA_CERT_B64` and the system trust store, both of which are + themselves runtime state, not something `spec.yaml` can compute ahead of + time. The startup script, which reruns this derivation every boot, is the + only point in the lifecycle where both inputs are known to be current — + the wrapper itself just carries forward the path that derivation produced. +- **Bake the bundle once at install time to a static file, reference it via + a persisted var.** Solves persistence but not freshness: doesn't pick up a + changed proxy CA / trust store on a later boot without re-running the + install step, which is explicitly a no-op once `pi` is present (see + `pi-coding-agent-install.sh`'s idempotency guard). + +## Consequences + +- **Positive:** `pi` gets a correct, current CA trust bundle on every + invocation, in every session, not just the one process that happened to + install it. +- **Positive:** consistent with the family's wrapper pattern — a reviewer + familiar with `openchamber`'s or `paseo`'s wrapper immediately recognizes + the shape here. +- **Neutral:** adds one extra process/exec hop per `pi` invocation (the + wrapper, then the real binary) — negligible cost for a TUI startup. +- **Negative / residual note:** the wrapper is rewritten by the install + script on every boot (not just on a fresh install), so it self-heals a + sandbox that already had `pi` installed from before this wrapper existed, + and so it can point at that boot's freshly-rebuilt bundle path. That means + the install script must run its full CA-bundle-and-wrapper logic + unconditionally, not only inside its "pi is missing" branch — see the + install script's own comment on why the bundle build and the wrapper + rewrite both sit outside that guard. + +## Links + +- `../files/home/pi-coding-agent-install.sh` — the install-time CA-bundle + block this decision's boot-time wrapper rewrite parallels. +- [`local-prefix-not-npm-global.md`](local-prefix-not-npm-global.md) — why the + wrapper lives at `$HOME/.local/bin/pi` specifically. +- `../../openchamber/docs/decisions/wrapper-entrypoint-owns-server.md` — the + family's precedent for a PATH-shadowing wrapper that `exec`s the real + binary. +- `../../paseo/docs/decisions/paseo-mixin-generic-wrapper.md` — a second + precedent, for a generic (non-opencode-coupled) wrapper shim. diff --git a/integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/local-prefix-not-npm-global.md b/integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/local-prefix-not-npm-global.md new file mode 100644 index 00000000..67aaf55d --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/local-prefix-not-npm-global.md @@ -0,0 +1,108 @@ +# Decision: `$HOME/.local` as the npm install prefix, not `$HOME/.npm-global` + +**Status:** accepted + +## Context + +Every unprivileged Node-CLI kit in this family needs a per-user npm prefix — a +sandbox startup script must not depend on the (often root-owned) system global +prefix. The sibling `openchamber` and `paseo` kits both chose +`$HOME/.npm-global` for this. That choice has a real, load-bearing cost that +both kits pay for in their own `scripts/verify`: + +```sh +# openchamber's and paseo's own in_sbx() helper, verbatim comment: +# PATH SCOPING: `acq exec`/`sbx exec` run the probe as a bare non-login `sh -c` +# … A bare `sh -c` sources NOTHING … so its PATH is only whatever +# `/etc/environment` provides, which does NOT include the npm-global bin … +``` + +Concretely: `$HOME/.npm-global/bin` is **not** on the sandbox base image's +default `PATH` for a fresh, non-login shell. Both sibling kits' startup +scripts can find their own installed binary while they're still running +(they prepend the prefix's `bin/` to their *own* process's `PATH` before +installing), but any **later**, **separate** process — a freshly-attached +`acq exec`, a fresh `acq run` session, or a human just typing the binary's +name at a TUI prompt — does not see it unless something re-injects that PATH +entry every time. `openchamber`/`paseo` solve this by making every +`scripts/verify` probe inject `$HOME/.local/bin:$HOME/.npm-global/bin:$(npm +prefix -g)/bin` ahead of `$PATH` on every single exec — a workaround baked +into the test harness, not a fix to the underlying prefix choice. + +This is exactly the failure mode a PR review for this kit flagged directly: +"a verify that probes `command -v pi` through a bare `sh -c` would have +caught [a `pi`-not-found-after-install] finding." A verify script that has to +carry its own PATH-injection workaround to pass is, definitionally, a verify +script that would **not** catch a regression where the real, unmodified guest +PATH stops resolving the binary — because the workaround masks exactly that +class of bug. + +By contrast, `$HOME/.local/bin` **is already first on the guest's default +PATH** — verified against the base image directly (the same fact the sibling +`openchamber` kit's own `wrapper-entrypoint-owns-server.md` ADR documents when +explaining why an `opencode` wrapper at `~/.local/bin/opencode` can shadow the +real binary: "the image PATH puts `/home/agent/.local/bin` FIRST"). Anything +installed there resolves for every fresh shell, with no PATH injection needed +anywhere — in the install script, in `scripts/verify`, or for the human at the +terminal. + +## Decision + +**Install `pi` under the `$HOME/.local` prefix (`npm install -g --prefix +"$HOME/.local" …`), not `$HOME/.npm-global`.** + +This is a deliberate divergence from the `openchamber`/`paseo` convention, not +an oversight — see `pi-coding-agent-install.sh`'s own inline comment +contrasting the two mechanisms. The reasoning: + +- `$HOME/.local/bin` is on the default guest `PATH` for every process, not + just the installer's own. No PATH-injection workaround is needed anywhere + downstream — not in this kit's `scripts/verify`, not for a human attaching + a fresh session. +- It closes off, by construction, the exact regression class the review + finding describes: an install that "succeeds" from the installer's own + point of view but is invisible to every other process. +- It keeps this kit's threat/verification model simpler than its siblings', + consistent with this being "the SIMPLEST kit in this family" (per + `spec.yaml`'s own header note) — one less moving part (a PATH-injection + convention that has to be replicated correctly in every consumer) to get + wrong. + +## Consequences + +- **Positive:** `scripts/verify`'s `in_sbx` helper for this kit injects + **nothing** into `PATH` (contrast openchamber's/paseo's `in_sbx`, which + both must). Its step 4 — a bare `command -v pi` probe — is therefore a + genuine regression guard: if a future change reintroduces a + non-default-PATH prefix, that step fails instead of silently passing + through an injected workaround. See `scripts/verify`'s own comment on + `in_sbx` for the explicit rationale. +- **Positive:** no divergence between "works for the script that installed + it" and "works for the user who wants to run it" — the single most + confusing class of bug this family of kits has hit so far. +- **Neutral:** the `pi` binary itself is not a plain npm bin-link at + `~/.local/bin/pi` — it is a thin wrapper (see + [`ca-bundle-wrapper-not-env-var.md`](ca-bundle-wrapper-not-env-var.md)) that + execs the real npm-installed binary after rebuilding + `NODE_EXTRA_CA_CERTS`. The `$HOME/.local` prefix decision and the wrapper + decision are independent but composed: the wrapper lives at + `$HOME/.local/bin/pi` specifically because that is the default-PATH + location this ADR chose. +- **Negative / residual difference from siblings:** this kit's install + layout is not identical to `openchamber`'s/`paseo`'s convention, so a + reader moving between kits needs to notice the prefix differs. This is + documented here, in `TROUBLESHOOTING.md`, and in `scripts/verify`'s own + comment specifically so it isn't mistaken for an oversight. + +## Links + +- `../../openchamber/scripts/verify` — `in_sbx`'s own comment explaining why + it injects `$HOME/.npm-global/bin` (the workaround this decision avoids + needing). +- `../../paseo/scripts/verify` — the same workaround, independently + duplicated. +- `../../openchamber/docs/decisions/wrapper-entrypoint-owns-server.md` — the + base-image fact (`~/.local/bin` first on `PATH`) this decision also relies + on. +- [`ca-bundle-wrapper-not-env-var.md`](ca-bundle-wrapper-not-env-var.md) — the + companion decision for the wrapper that lives at this prefix. diff --git a/integrations/isolation/acq-kits/pi-coding-agent/files/home/pi-coding-agent-install.sh b/integrations/isolation/acq-kits/pi-coding-agent/files/home/pi-coding-agent-install.sh new file mode 100755 index 00000000..f37aff2d --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/files/home/pi-coding-agent-install.sh @@ -0,0 +1,245 @@ +#!/bin/sh +# pi-coding-agent-install.sh — install the pi CLI (@earendil-works/pi-coding-agent) +# via the standard npm registry path, unprivileged, on every sandbox start. +# +# SCOPE: this is the STARTUP-phase install step for the pi-coding-agent kit +# (see spec.yaml's header DESIGN note for why this is startup-only, not an +# install phase — no native-module lifecycle script exists to fail without a +# toolchain, unlike openchamber's better-sqlite3 dependency, so there is no +# create-vs-startup safety tension to resolve here). Runs as the agent user +# (whose uid is assigned at provision and is not necessarily 1000) and is +# fully idempotent: installs `pi` only if it isn't already present. The +# CA-bundle and the `pi` wrapper (both below) are rebuilt EVERY boot, +# regardless of whether a fresh install happened — see the comments at each. +# +# DELIBERATELY NOT the pi.dev curl-pipe-to-shell installer: that script is +# mutable, runs an interactive Node/npm preflight path meaningless in a +# non-interactive startup script, and needs pi.dev egress this kit's +# allow-list does not grant (see spec.yaml). This script instead runs the +# same underlying npm command pi's own installer runs, directly. +# +# VERSION PIN: PI_CODING_AGENT_VERSION selects the exact npm version to +# install. Pinned to a specific, reviewed release rather than "latest" — this +# kit's behavior must not silently drift to whatever npm resolves as newest +# on every sandbox boot. Bump this pin deliberately, as its own reviewed +# change, after checking the new release's own supply-chain posture (npm +# registry metadata: engines.node, absence of lifecycle scripts). +_pi_version="${PI_CODING_AGENT_VERSION:-0.84.3}" + +set -eu + +# --- Preflight: Node/npm must already be present. -------------------------- +# THIS GUARD MUST RUN FIRST, before any use of `npm` below (including the npm +# prefix lookup) — under `set -eu`, a command substitution invoking a +# nonexistent `npm` binary trips errexit immediately with an opaque +# "command not found", bypassing the friendly message and non-fatal exit(0) +# this guard exists to provide. Installing Node itself is out of scope for +# this kit — the base image is expected to already carry it (the same +# assumption openchamber makes). pi's own installer offers an interactive +# Homebrew/apt/apk/standalone-Node install path when Node/npm are missing; +# that path is meaningless in a non-interactive startup script, so we skip it +# entirely and fail CLEARLY instead. +if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "pi-coding-agent: node and/or npm not found on PATH; cannot install pi this boot (installing Node is out of scope for this kit — see spec.yaml)" >&2 + exit 0 # never fail the sandbox over an optional CLI +fi + +# pi requires Node >=22.19.0 (verified against its published npm registry +# metadata's engines.node field). npm's own engine-strict defaults to false, +# so `npm install` would otherwise proceed silently on an older Node and only +# fail later, opaquely, the first time `pi` itself runs. Check explicitly and +# fail clearly now instead — mirrors pi's own installer's preflight check. +if ! node -e 'var v=process.versions.node.split(".").map(Number);process.exit(v[0]>22||(v[0]===22&&v[1]>=19)?0:1)' 2>/dev/null; then + echo "pi-coding-agent: pi requires Node.js >=22.19.0, found $(node --version 2>/dev/null); cannot install pi this boot (installing/upgrading Node is out of scope for this kit)" >&2 + exit 0 +fi + +# Defensive format check on the version pin: a malformed +# PI_CODING_AGENT_VERSION (e.g. accidentally containing whitespace, shell +# metacharacters, or a stray flag-looking token) fails closed here with a +# clear message instead of silently becoming part of the npm package-spec +# string later. NOTE: a shell `case` glob's `*` is a WILDCARD, not a +# quantifier — `[0-9]*.[0-9]*.[0-9]*` matches "one digit followed by ANY +# characters" three times, so a string like "1.0.0; rm -rf /" or +# "1.0.0 --registry=evil" MATCHES this pattern too, so a format check using +# only this glob would be a silent no-op against exactly the strings it is +# meant to catch (it is not a shell-injection risk either way — the value is +# always passed as a single quoted argv word, never re-parsed by the shell — +# but a malformed pin should still be refused loudly rather than reaching the +# npm package-spec string unchecked). The second `case` below closes that gap +# by explicitly rejecting any character outside a safe version-string +# charset. +case "$_pi_version" in + latest) : ;; + [0-9]*.[0-9]*.[0-9]*) + case "$_pi_version" in + *[!0-9A-Za-z.-]*) + echo "pi-coding-agent: PI_CODING_AGENT_VERSION='$_pi_version' contains characters outside [0-9A-Za-z.-]; refusing to install" >&2 + exit 0 + ;; + esac + ;; + *) + echo "pi-coding-agent: PI_CODING_AGENT_VERSION='$_pi_version' doesn't look like a version; refusing to install" >&2 + exit 0 + ;; +esac + +# Single shared, FIXED log path for this boot's run (see +# docs/decisions/ for why fixed, not mktemp). +_log="$HOME/.local/state/pi-coding-agent/install.log" +mkdir -p "$(dirname "$_log")" +: > "$_log" + +# Explicit, unprivileged per-user npm prefix — see +# docs/decisions/local-prefix-not-npm-global.md for why $HOME/.local. +NPM_PREFIX="$HOME/.local" + +# Build a CA bundle for Node's HTTPS requests, unconditionally, every boot — +# see docs/decisions/ca-bundle-wrapper-not-env-var.md. +_ca="$HOME/.local/state/pi-coding-agent/ca-bundle.pem" +mkdir -p "$(dirname "$_ca")" +: > "$_ca" +if [ -n "${PROXY_CA_CERT_B64:-}" ]; then + if ! printf %s "$PROXY_CA_CERT_B64" | base64 -d >> "$_ca" 2>>"$_log"; then + # Surface a decode failure instead of silently shipping a bundle missing + # the proxy CA — an incomplete bundle behind an inspecting proxy fails + # TLS with a confusing "unable to verify" error rather than a clear + # signal that PROXY_CA_CERT_B64 itself is malformed. + echo "pi-coding-agent: PROXY_CA_CERT_B64 failed to base64-decode; proxy CA NOT added to the bundle (see $_log)" >&2 + fi +fi +[ -f /etc/ssl/certs/ca-certificates.crt ] && cat /etc/ssl/certs/ca-certificates.crt >> "$_ca" +[ -s "$_ca" ] && export NODE_EXTRA_CA_CERTS="$_ca" + +# --- Install pi if it isn't present yet (idempotent). ----------------------- +if ! command -v pi >/dev/null 2>&1; then + mkdir -p "$NPM_PREFIX" + # `--prefix` on the command line (below), not just an npm_config_prefix + # env-var export: some base images persistently export NPM_CONFIG_PREFIX + # (uppercase), which npm's config precedence reads over a lowercase + # npm_config_prefix export — the CLI flag always wins regardless. + + # The standard install path pi's own installer itself runs — NOT the + # experimental PI_EXPERIMENTAL=1 managed-install mode. --ignore-scripts + # residual risk: see docs/decisions/ for the full analysis; in short, it + # blocks npm lifecycle hooks at install time only, not code that runs when + # `pi` is later invoked. + _pkg="@earendil-works/pi-coding-agent" + [ "$_pi_version" != "latest" ] && _pkg="${_pkg}@${_pi_version}" + + # --fetch-timeout bounds a hanging (not merely erroring) registry + # connection; --fetch-retries=0 because the next boot already retries. + if npm install -g --ignore-scripts --prefix "$NPM_PREFIX" \ + --fetch-timeout=30000 --fetch-retries=0 \ + "$_pkg" >>"$_log" 2>&1; then + : + else + _rc=$? + # Surface the real exit code; still exit 0 below — an optional CLI must + # not fail the sandbox — but a definite install failure stays visible in + # the log rather than being silently swallowed. + echo "pi-coding-agent: npm install exited $_rc (prefix=$NPM_PREFIX); pi unavailable this boot. See $_log" \ + | tee -a "$_log" >&2 + fi +fi + +# --- Wrapper: make the CA bundle actually apply to a USER's later `pi`. ----- +# Rebuilt UNCONDITIONALLY (regardless of whether a fresh install happened +# above), for two reasons: (1) it must self-heal a sandbox that already had +# `pi` installed from a boot BEFORE this wrapper existed, and (2) the CA +# bundle it points at is rebuilt every boot (see above), so the wrapper must +# be too, or it would keep exporting a stale bundle path forever after the +# first boot that created it. +# +# WHY A WRAPPER, NOT JUST THE NODE_EXTRA_CA_CERTS EXPORT ABOVE: see +# docs/decisions/ca-bundle-wrapper-not-env-var.md — that export is scoped to +# this script's own process and cannot reach a `pi` a user launches later. +# +# _pi_real is whatever npm's own global-install bin symlink actually points +# at — resolved via readlink rather than hardcoding pi's internal package +# layout, so this does not silently break if a future pi release restructures +# its own dist/ tree. That symlink only EXISTS right after npm creates it, +# though: once this script has replaced it with the wrapper below (a plain +# file, not a symlink), a LATER boot has nothing left to readlink from — so +# the resolved path is cached in a sidecar file the first time it's found, +# and read back from there on every subsequent boot where $_pi_bin is +# already our own wrapper rather than npm's symlink. +_pi_real_cache="$HOME/.local/state/pi-coding-agent/real-bin-path" +_pi_bin="$NPM_PREFIX/bin/pi" +_pi_real="" +if [ -L "$_pi_bin" ]; then + _pi_real="$(readlink "$_pi_bin" 2>/dev/null || true)" + case "$_pi_real" in + /*) : ;; # already absolute + "") : ;; # readlink failed; handled below + *) _pi_real="$NPM_PREFIX/bin/$_pi_real" ;; # resolve relative to bin/ + esac + if [ -n "$_pi_real" ]; then + mkdir -p "$(dirname "$_pi_real_cache")" + printf '%s\n' "$_pi_real" > "$_pi_real_cache" + fi +elif [ -e "$_pi_bin" ] && [ -f "$_pi_real_cache" ]; then + # $_pi_bin already our wrapper from an earlier boot (a plain file, not + # npm's symlink) — recover the real path from the cache written above the + # first time this ever ran. + _pi_real="$(cat "$_pi_real_cache" 2>/dev/null || true)" +fi + +if [ -n "$_pi_real" ] && [ -e "$_pi_real" ]; then + # `> "$_pi_bin"` alone would be WRONG here on the very first rewrite: at + # that point $_pi_bin is still npm's own symlink (from the readlink call + # just above), and a shell redirection into a symlink follows it and + # truncates the REAL target file underneath — which is $_pi_real itself, + # i.e. this would silently clobber the actual `pi` binary with the + # wrapper's own shell-script text, and Node would then fail to parse the + # clobbered file as JS. Remove the symlink first so the redirection + # creates a fresh regular file at $_pi_bin instead. + rm -f "$_pi_bin" + { + cat < "$_pi_bin" + chmod 0755 "$_pi_bin" +elif [ ! -e "$_pi_bin" ]; then + : # No install this boot and none from a prior boot either; nothing to wrap. +else + echo "pi-coding-agent: could not resolve $_pi_bin's real target; leaving it unwrapped (pi will run without the managed CA bundle) — see $_log" >&2 +fi + +command -v pi >/dev/null 2>&1 || { + # This fires for ANY unsuccessful install, including TRANSIENT causes + # (npm/registry 503, proxy blip) — it is not a definitive-failure signal on + # its own, same caveat openchamber's script documents for its own + # equivalent guard. + echo "pi-coding-agent: pi install failed or pi not found on PATH" >&2 + exit 0 # never fail the sandbox over an optional CLI +} + +echo "pi-coding-agent: pi is installed and on PATH ($(command -v pi))" diff --git a/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify b/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify new file mode 100755 index 00000000..83143d3f --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# +# verify — host-side validation for the pi-coding-agent acq mixin kit. +# +# Two layers, matching the sibling openchamber/paseo kits: +# +# 1. OFFLINE GATE (always runs): the backend-agnostic validator +# integrations/isolation/acq-kits/validate-kits.py (schema + files[].source +# + registry), plus source-level regression guards (5b) for behaviours that +# are otherwise only exercised live, once, in a PR description. Needs +# python3 with jsonschema + pyyaml; if those aren't importable this check +# reports SKIP (with the fix), not a false FAIL. +# +# 2. LIVE END-TO-END (opt-in): create a real sandbox with the kit applied, +# wait for the startup install, then assert `pi` is reachable and runnable, +# that a second run is idempotent, that a malformed version pin is +# rejected without touching the existing install, and that a malformed +# PROXY_CA_CERT_B64 is surfaced rather than silently swallowed. +# +# Preferred driver — RUN_ACQ=1: drive the `acq` CLI. acq translates the +# neutral hybrid/v1 spec to an sbx-v2 kit (kit_translate_to_sbx) and +# applies it. +# +# Legacy driver — RUN_SBX=1: drive `sbx` directly on a PRE-TRANSLATED +# sbx-v2 kit dir (SBX_KIT_DIR) — sbx cannot parse the neutral schema. +# Without SBX_KIT_DIR this path SKIPs with guidance. Prefer RUN_ACQ=1. +# +# CLI SHAPE: no positional arguments — this matches openchamber's and paseo's +# own scripts/verify exactly (both generate their own unique sandbox name +# internally, e.g. SBX_NAME="openchamber-verify-$$", and are driven entirely by +# env vars: RUN_ACQ / RUN_SBX / KEEP / READY_TIMEOUT / etc.). This script +# follows the same convention rather than inventing a `verify ` +# CLI the sibling kits don't use. +# +# Usage (from anywhere): +# path/to/pi-coding-agent/scripts/verify # offline gate only (default) +# RUN_ACQ=1 path/to/.../scripts/verify # live, via acq (preferred) +# KEEP=1 RUN_ACQ=1 path/to/.../scripts/verify # keep the sandbox to inspect +# RUN_SBX=1 SBX_KIT_DIR= .../verify # live, via sbx (needs sbx-v2 dir) + +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/pi-coding-agent-install.sh" +SBX_NAME="pi-coding-agent-verify-$$" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/pi-coding-agent-verify.XXXXXX")" +CREATE_LOG="$WORK/create.log" +KEEP="${KEEP:-0}" +RUN_ACQ="${RUN_ACQ:-0}" +RUN_SBX="${RUN_SBX:-0}" +SBX_KIT_DIR="${SBX_KIT_DIR:-}" +# pi has no native-module postinstall step and no server to wait on (contrast +# openchamber's better-sqlite3 prebuild download) — a plain `npm install -g` of +# a small package, so the default timeout is much shorter than the sibling +# kits'. Override with READY_TIMEOUT if your network is slow. +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 + case "${DRIVER:-}" in + acq) acq rm "$SBX_NAME" >/dev/null 2>&1 || true ;; + *) sbx rm -f "$SBX_NAME" >/dev/null 2>&1 || true ;; + esac + rm -rf "$WORK" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +# --- Driver abstraction: exec a command inside the sandbox (non-TTY). -------- +# +# DELIBERATE DIFFERENCE FROM openchamber/paseo's own in_sbx: THIS in_sbx does +# **NOT** prepend anything to PATH. openchamber and paseo both have to inject +# `$HOME/.npm-global/bin` (and `~/.local/bin`) because a bare non-login `sh -c` +# sources no profile, so their installed binaries — which land in a non-default +# npm-global prefix — are otherwise invisible to a fresh probe. pi-coding-agent's +# installer uses `$HOME/.local` as its npm prefix specifically BECAUSE +# `~/.local/bin` is already first on the guest's default PATH (see +# docs/decisions/ — the install-prefix ADR), so `pi` should resolve from a +# bare `sh -c` with NO PATH massaging at all. +# +# This is intentional and load-bearing for what this script exists to catch: +# a verify that probes `command -v pi` through a bare `sh -c` catches a +# `pi`-installed-but-not-on-the-default-PATH regression. If a future +# regression puts `pi` back behind a non-default-PATH prefix, THIS script's +# step 4 below must FAIL, not quietly route around it with a PATH injection +# the way the sibling kits do — that PATH injection is exactly what would +# hide the regression again. +in_sbx() { + case "${DRIVER:-}" in + acq) acq exec "$SBX_NAME" -- sh -c "$1" /dev/null || true ;; + *) sbx exec "$SBX_NAME" -- sh -c "$1" /dev/null || true ;; + esac +} + +info "0. Preconditions" +[ -f "$KIT_DIR/spec.yaml" ] && ok "kit spec.yaml present" || { bad "spec.yaml missing"; exit 1; } +[ -f "$INSTALL_SCRIPT" ] && ok "install script present" || { bad "files/home/pi-coding-agent-install.sh missing"; exit 1; } + +info "1. Validate the neutral kit spec" +VALIDATOR="$REPO_ROOT/integrations/isolation/acq-kits/validate-kits.py" +if ! command -v python3 >/dev/null 2>&1; then + skip "validate-kits.py not run (python3 not on PATH)" +elif [ ! -f "$VALIDATOR" ]; then + skip "validate-kits.py not found at $VALIDATOR" +elif ! python3 -c 'import jsonschema, yaml' >/dev/null 2>&1; then + skip "validate-kits.py needs 'jsonschema' + 'pyyaml' — install them (pip install jsonschema pyyaml) and re-run" +else + if val_out="$(python3 "$VALIDATOR" --root "$REPO_ROOT" 2>&1)"; then + ok "acq-kits validate-kits.py passes (schema + registry)" + else + bad "validate-kits.py failed:" + printf '%s\n' "$val_out" | sed 's/^/ | /' >&2 + fi +fi + +# --- Offline source checks (no sandbox needed). ------------------------------ +# These regression guards exist because the live-tested behaviours +# (idempotent re-run, bad-version rejection, old-Node rejection, CA-decode +# logging) would otherwise go unchecked between runs. Steps 6-8 below +# re-test these LIVE where practical (idempotent re-run, bad-version +# rejection, CA-decode logging). The old-Node rejection is checked here +# OFFLINE instead, at the source level — reliably forcing the guest's own +# Node to report a stale version without patching the base image is +# impractical for a host-side script, so the regression guard is the presence +# of the version-gate logic itself. +info "1b. Source-level regression guards (offline)" +if grep -Eq "process\.versions\.node" "$INSTALL_SCRIPT" && grep -Eq '22' "$INSTALL_SCRIPT"; then + ok "install script gates on Node's own version (old-Node rejection present)" +else + bad "install script no longer checks Node's version — old-Node installs would fail late and opaquely instead of being rejected up front" +fi +if grep -Eq 'PI_CODING_AGENT_VERSION' "$INSTALL_SCRIPT" && grep -Eq 'refusing to install' "$INSTALL_SCRIPT"; then + ok "install script validates PI_CODING_AGENT_VERSION format (bad-version rejection present)" +else + bad "install script no longer validates the version-pin format — a malformed PI_CODING_AGENT_VERSION could reach the npm package-spec string unchecked" +fi +if grep -Eq 'base64 -d' "$INSTALL_SCRIPT" && grep -Eq 'failed to base64-decode' "$INSTALL_SCRIPT"; then + ok "install script surfaces a PROXY_CA_CERT_B64 decode failure (CA-decode logging present)" +else + bad "install script no longer surfaces a CA decode failure — a malformed PROXY_CA_CERT_B64 could silently ship an incomplete trust bundle" +fi +if grep -Eq 'command -v pi >/dev/null 2>&1; then' "$INSTALL_SCRIPT"; then + ok "install script is guarded to be idempotent (skips the npm install when pi is already present)" +else + bad "install script no longer guards against re-installing on every boot — idempotency regression" +fi + +# --- Select the live driver. ------------------------------------------------- +DRIVER="" +if [ "$RUN_ACQ" = "1" ]; then + DRIVER="acq" +elif [ "$RUN_SBX" = "1" ]; then + DRIVER="sbx" +fi + +if [ -z "$DRIVER" ]; then + info "Live sandbox checks (2-8): SKIPPED" + echo " Set RUN_ACQ=1 to run the live end-to-end check via acq (preferred)." >&2 + echo " (RUN_SBX=1 also works but needs a pre-translated sbx-v2 kit; see header.)" >&2 + info "Summary" + printf ' %d passed, %d failed\n' "$pass" "$fail" + [ "$fail" -eq 0 ] && printf ' \033[32mOffline gate passed.\033[0m\n' || printf ' \033[31mOffline gate failed — see above.\033[0m\n' + exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" +fi + +if ! command -v "$DRIVER" >/dev/null 2>&1; then + echo " RUN_${DRIVER^^}=1 set but '$DRIVER' not on PATH. Install it and retry." >&2 + exit 1 +fi +echo " driver: $DRIVER" + +info "2. Create a sandbox with the kit" +if [ "$DRIVER" = "acq" ]; then + echo " creating $SBX_NAME via acq (extra kit: $KIT_DIR)..." + if ACQ_EXTRA_KITS="$KIT_DIR" acq create --name "$SBX_NAME" opencode "$WORK" "$CREATE_LOG" 2>&1; then + ok "sandbox created via acq (kit translated + applied)" + else + bad "acq create failed (exit $?)" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 + fi +else + if [ -z "$SBX_KIT_DIR" ]; then + info "Live sandbox checks (2-8): SKIPPED" + echo " RUN_SBX=1 needs a PRE-TRANSLATED sbx-v2 kit dir in SBX_KIT_DIR." >&2 + echo " sbx cannot parse the neutral hybrid/v1 spec directly. Prefer RUN_ACQ=1," >&2 + echo " which translates automatically." >&2 + info "Summary" + printf ' %d passed, %d failed\n' "$pass" "$fail" + [ "$fail" -eq 0 ] && printf ' \033[32mOffline gate passed; live checks skipped.\033[0m\n' \ + || printf ' \033[31mOffline gate failed — see above.\033[0m\n' + exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" + fi + echo " creating $SBX_NAME (workspace $WORK) from translated kit $SBX_KIT_DIR..." + if sbx create --name "$SBX_NAME" --kit "$SBX_KIT_DIR" opencode "$WORK" "$CREATE_LOG" 2>&1; then + ok "sandbox created with --kit" + else + bad "sbx create failed (exit $?)" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 + fi +fi + +info "3. Wait for the startup install (up to ${READY_TIMEOUT}s)" +# pi has no server to wait on — just wait for the startup phase's npm install to +# land the binary. No native-module postinstall (contrast openchamber's +# better-sqlite3), so this should be quick. +installed="" +deadline=$(( $(date +%s) + READY_TIMEOUT )) +while :; do + [ -n "$(in_sbx 'command -v pi')" ] && { installed=yes; break; } + [ "$(date +%s)" -ge "$deadline" ] && break + sleep "$POLL_INTERVAL" +done + +info "4. \`pi\` reachable via a BARE, fresh \`sh -c\` probe — the exact check this file exists to run" +# This is the specific check that would catch a `pi`-installed-but-not-on- +# the-default-PATH regression. in_sbx above injects NO PATH (see its own +# comment) — command -v here resolves purely off whatever PATH a fresh +# non-login shell gets from the guest's own /etc/environment, i.e. the SAME +# PATH a freshly-attached `acq run`/`acq exec` session would have. If `pi` +# installs somewhere not on that default PATH (e.g. a regression back to a +# bare `$HOME/.npm-global` prefix with no default-PATH-visible shim), this +# step FAILS — which is the intended, load-bearing behavior, not a false +# positive to work around. +pi_path="$(in_sbx 'command -v pi')" +if [ -n "$pi_path" ]; then + ok "pi resolves on the default guest PATH via a bare sh -c ($pi_path)" +else + bad "pi NOT found via a bare, unmodified \`sh -c\` probe — either the install failed (see step 3/5) or pi installed to a prefix that is not on the guest's DEFAULT PATH (the exact regression class this check exists to catch)" +fi + +info "4b. \`pi\` is the CA-bundle wrapper, not npm's bare bin link" +# Step 4 above only proves SOMETHING resolves at `command -v pi` on the +# default PATH — it does not prove that something is THIS kit's wrapper (see +# docs/decisions/ca-bundle-wrapper-not-env-var.md) rather than npm's plain +# bin symlink straight to the package's entrypoint. Without the wrapper, +# `pi` runs with no NODE_EXTRA_CA_CERTS at all on an inspecting network, and +# every LLM-provider call fails TLS. +wrapper_ok="$(in_sbx 'grep -q NODE_EXTRA_CA_CERTS "$(command -v pi)" && echo yes')" +if [ "$wrapper_ok" = "yes" ]; then + ok "\`pi\` is the wrapper and exports NODE_EXTRA_CA_CERTS" +else + bad "\`pi\` does NOT export NODE_EXTRA_CA_CERTS — the wrapper is missing or has regressed to npm's bare bin link" +fi + +info "5. Real \`pi --version\` output — not just command -v" +# command -v only proves a file exists at that path; it does not prove the +# binary actually RUNS. Capture and print the real version output. +ver_out="$(in_sbx 'pi --version 2>&1')" +ver_rc_ok="$(in_sbx 'pi --version >/dev/null 2>&1 && echo yes')" +if [ -n "$installed" ] && [ "$ver_rc_ok" = "yes" ] && [ -n "$ver_out" ]; then + ok "pi --version runs and prints output: ${ver_out}" +elif [ -z "$installed" ]; then + bad "pi was never installed by the startup step within ${READY_TIMEOUT}s" + in_sbx 'tail -n 25 "$HOME/.local/state/pi-coding-agent/install.log" 2>/dev/null || true' | sed 's/^/ | /' +else + bad "pi is on PATH but \`pi --version\` did not run cleanly (output: '${ver_out}')" +fi + +info "6. Idempotent re-run — running the install script again is a clean no-op" +# Re-invoke the SAME startup script directly (not just rely on a second +# sandbox boot, which this script does not perform) and confirm it exits 0 +# and does not clobber the existing binary or re-download anything +# unnecessarily. The script's own guard is +# `if ! command -v pi >/dev/null 2>&1; then …install… fi`; with pi already +# present this should be fast and silent about installing. +if [ -z "$installed" ]; then + skip "idempotent re-run check (pi was never installed — nothing to re-run against)" +else + rerun_out="$(in_sbx 'sh /home/agent/pi-coding-agent-install.sh 2>&1; echo "RC=$?"')" + pi_still_there="$(in_sbx 'command -v pi')" + case "$rerun_out" in + *RC=0*) + if [ -n "$pi_still_there" ]; then + ok "re-running the install script is idempotent (exit 0, pi still on PATH)" + else + bad "re-running the install script exited 0 but pi is no longer on PATH afterward" + fi + ;; + *) + bad "re-running the install script did not exit cleanly: $rerun_out" + ;; + esac +fi + +info "7. Bad-version rejection — a malformed PI_CODING_AGENT_VERSION is refused, not installed" +# Invoke the script directly with an obviously-invalid pin and confirm it +# logs a refusal and exits 0 (never fails the sandbox over an optional CLI) +# WITHOUT disturbing the already-installed pi from step 3. +bad_ver_out="$(in_sbx 'PI_CODING_AGENT_VERSION="1.0.0; rm -rf /" sh /home/agent/pi-coding-agent-install.sh 2>&1; echo "RC=$?"')" +pi_after_bad_ver="$(in_sbx 'command -v pi')" +case "$bad_ver_out" in + *"refusing to install"*RC=0*|*RC=0*"refusing to install"*) + if [ -n "$pi_after_bad_ver" ]; then + ok "malformed PI_CODING_AGENT_VERSION is refused (exit 0, existing pi install untouched)" + else + bad "malformed PI_CODING_AGENT_VERSION was refused but the existing pi install is now missing" + fi + ;; + *) + bad "malformed PI_CODING_AGENT_VERSION was NOT clearly refused (output: $bad_ver_out)" + ;; +esac + +info "8. CA-decode failure is surfaced, not silently swallowed" +# The CA-bundle block runs UNCONDITIONALLY on every boot (see the install +# script's own comment above that block), not only inside the "pi is +# missing" install branch — so simply re-running the install script with a +# garbage PROXY_CA_CERT_B64 is enough to force the decode-failure path. +# No need to remove pi or force a reinstall first. +ca_out="$(in_sbx 'PROXY_CA_CERT_B64="not-valid-base64!!!" sh /home/agent/pi-coding-agent-install.sh 2>&1')" +case "$ca_out" in + *"failed to base64-decode"*) + ok "a malformed PROXY_CA_CERT_B64 is surfaced with a clear decode-failure message" + ;; + *) + bad "a malformed PROXY_CA_CERT_B64 was NOT surfaced with a decode-failure message (output: $ca_out)" + ;; +esac + +info "Summary" +[ "$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/pi-coding-agent/spec.yaml b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml new file mode 100644 index 00000000..8b921697 --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml @@ -0,0 +1,133 @@ +# spec.yaml — pi-coding-agent (a neutral acq mixin kit, hybrid/v1) +# +# Installs earendil-works/pi (MIT), npm package @earendil-works/pi-coding-agent +# — a plain terminal AI coding agent that grants the model only read/write/ +# edit/bash tools by DEFAULT (the binary ships a few more built-in tools — +# grep/find/ls/powershell — but they are not exposed to the model unless +# explicitly allowlisted via --tools; verified against pi's own README). By +# upstream design philosophy ("features other agents bake in, you build with +# extensions") pi has no built-in MCP support, no sub-agents, no permission +# popups, and no plan mode — and, unlike the sibling prime-agent kit, NO +# embedded Python/IPython kernel to bootstrap. This is the SIMPLEST kit in +# this family: ONE install step, at STARTUP, unprivileged, no install phase, +# no supervisor loop. +# +# SEQUENCING NOTE (per a completed consensus vote): this kit is being shipped +# AHEAD of the in-flight prime-agent kit (patterns epic #368) specifically to +# validate the unprivileged/agent-owned/startup-only pattern on a materially +# simpler target — pi's own installer needs no native-module postinstall step +# and no Python runtime, so there is no "install phase vs. startup phase, +# because a failed native build must not crash sandbox create" tension the way +# there is for openchamber's better-sqlite3 dependency. prime-agent's own +# heavier kernel-bootstrap complexity is deliberately deferred, not abandoned. +# +# WHAT pi-coding-agent IS: a TUI, not a web UI — like prime-agent (contrast +# openchamber / paseo, which each publish a browser-UI port). Publishes NO +# ports. +# +# Design rationale for the npm prefix choice and the CA-bundle wrapper lives +# in docs/decisions/. The install-method choice (npm registry, not +# pi.dev/install.sh) and the --ignore-scripts residual risk are each +# contained enough to stay where they're used: see README.md "Install +# method" and "Supply-chain notes", and this file's own commands: block and +# the install script's comments. +# +# MODEL PROVIDER CONFIG IS DELIBERATELY DEFERRED, not merely out of scope: +# pairing a models.json install step with a hand-written placeholder catalog +# (empty models[] array) provides ZERO functional value with no USAi-catalog +# pi emitter yet to populate it (contrast prime-agent's own placeholder, +# which ships a real, populated model list). Rather than ship functionless +# scaffolding, that step is cut from this kit entirely and will land +# together with the real emitter in a follow-up. This kit's own allow-list +# is pi's install host only; model-provider egress (e.g. api.gsa.usai.gov) +# remains the separate usai-provider kit's job either way. +# +# AGENTS.md / CLAUDE.md: pi supports project-level AGENTS.md/CLAUDE.md loading +# and a global ~/.pi/agent/AGENTS.md. This kit deliberately does NOT touch +# either — the same single-responsibility boundary prime-agent's kit draws +# around its own equivalent surface. Leave that to the user or another kit. +# +# NAMING: the kit is named `pi-coding-agent`, not the bare upstream +# shorthand `pi` — see README.md "Naming" for why. +schemaVersion: "hybrid/v1" +kind: mixin +name: pi-coding-agent +displayName: pi (earendil-works terminal coding agent) +description: > + Installs earendil-works/pi (MIT), npm package + @earendil-works/pi-coding-agent — a plain terminal AI coding agent that + grants the model only read/write/edit/bash tools by default (no built-in + MCP, sub-agents, permission popups, or plan mode). Installs via the + standard npm registry path (npm install -g --ignore-scripts, an + unprivileged per-user npm prefix, no pi.dev egress) at every sandbox start. + Publishes no ports (a TUI, not a web UI); no install phase — the single + install step is an unprivileged startup step, matching the family's + settled unprivileged-startup pattern. Model-provider config (USAi + models.json) is deliberately deferred to a follow-up once a USAi catalog + emitter for pi exists — see the header comment. Opt-in mixin; composes + with the USAi provider and the other GSA kits. + +caps: + network: + allow: + # The ONLY host this kit's install path needs: npm metadata AND + # tarballs both resolve from registry.npmjs.org (no wildcard subdomain + # needed — see openchamber's own note on why one host, unsuffixed, is + # sufficient). Deliberately NOT pi.dev and NOT any + # github.com/*.githubusercontent.com release-asset host — see + # README.md "Install method" for why. This kit's egress surface is + # intentionally the smallest in the family. + - registry.npmjs.org + +files: + # Lifecycle script. Mode 0755 (executable). Builds a per-user npm prefix + # (docs/decisions/local-prefix-not-npm-global.md), a per-boot CA trust + # bundle, and the runtime wrapper that carries that bundle into `pi`'s own + # invocations (docs/decisions/ca-bundle-wrapper-not-env-var.md). + - path: /home/agent/pi-coding-agent-install.sh + mode: "0755" + source: files/home/pi-coding-agent-install.sh + description: Install the pi CLI via npm (unprivileged, --ignore-scripts, per-user prefix, pinned version) at every start; no-op if already present. + +commands: + # STARTUP phase only — no install phase (see header DESIGN note). Runs to + # completion and exits; not `background: true` (pi has no server to keep + # alive, unlike openchamber's supervised UI). Schema-required: the + # hybrid/v1 `user` field pattern is ^[0-9]+$ (a numeric uid, NOT a name), + # so this MUST stay "1000" even though the agent uid is assigned at + # provision and is not necessarily 1000 — acq/the backend runs the startup + # phase as the agent user regardless of the literal value. + - phase: startup + user: "1000" + description: Install the pi CLI via npm (unprivileged, idempotent, pinned version) + command: + - sh + - /home/agent/pi-coding-agent-install.sh + +# NO publishedPorts: pi is a TUI, not a web UI (same as prime-agent). Nothing +# to publish to the host. + +agentContext: | + ## pi (earendil-works terminal coding agent) + + This sandbox has **pi** installed — `@earendil-works/pi-coding-agent`, a + plain terminal AI coding agent that grants the model only + `read`/`write`/`edit`/`bash` tools by default. There is no built-in MCP + support, no sub-agents, no permission popups, and no plan mode: pi is + intentionally minimal, and extensions are the documented way to add + capability. + + The kit installs the CLI on every sandbox start (idempotent — a no-op once + installed) via the standard npm registry path, unprivileged. Model-provider + configuration is not yet wired into this kit (see this kit's README for + status); until it is, authenticate pi manually (`/login`, an environment + variable, or `~/.pi/agent/auth.json` per pi's own docs). + + There is no published port and no supervisor loop — pi is a TUI you run + directly (`pi`), and this kit does not manage a shared session the way the + openchamber/paseo web-UI kits do. + +# No backend_shortcuts / backend_extras: this kit is written entirely in the +# neutral vocabulary (caps / files / commands), which acq's kit translation +# feeds to whatever backend it has active (sbx, msb). No published port and +# no background/supervisor primitive needed.