From 3e8ade8363533db48017d00e9def0a812ca1364b Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Fri, 28 Aug 2026 15:06:27 -0400 Subject: [PATCH 1/5] feat(patterns): add pi-coding-agent acq-kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installs earendil-works/pi (@earendil-works/pi-coding-agent) via the standard npm registry path, unprivileged, startup-phase-only — no install phase, matching openchamber's settled pattern rather than prime-agent's still-in-flight root-install-phase skeleton. 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 (no native-module postinstall, no Python/ IPython kernel bootstrap) before returning to prime-agent's heavier complexity. Deliberately avoids pi.dev/install.sh (a mutable live script with an interactive Node/npm preflight path) in favor of the same underlying npm command, pinned to a specific reviewed version rather than "latest". Model-provider config (USAi models.json) is deliberately deferred — an earlier draft shipped a functionless empty-array placeholder with no emitter to populate it; cut rather than shipped as scaffolding, to land later with a real pi.mjs emitter. Verified live on msb (fresh install, idempotent re-run, malicious version-string rejection, old-Node rejection, CA-decode-failure logging, registry-timeout bounding) and via validate-kits.py --strict, scan_unsafe_shell.py, and shellcheck. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/kits.yaml | 31 ++- .../acq-kits/pi-coding-agent/README.md | 147 +++++++++++++ .../files/home/pi-coding-agent-install.sh | 188 ++++++++++++++++ .../acq-kits/pi-coding-agent/spec.yaml | 203 ++++++++++++++++++ 4 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 integrations/isolation/acq-kits/pi-coding-agent/README.md create mode 100755 integrations/isolation/acq-kits/pi-coding-agent/files/home/pi-coding-agent-install.sh create mode 100644 integrations/isolation/acq-kits/pi-coding-agent/spec.yaml 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..ba05156b --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/README.md @@ -0,0 +1,147 @@ +# 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/.npm-global`). Idempotent: + a no-op if `pi` is already on `PATH`. + +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. Two reasons, both verified live: + +1. That script is a **mutable, live** script whose logic can change without + notice, and it runs an **interactive** Node/npm-install preflight path + that is meaningless in a non-interactive sandbox startup script. +2. `npm install -g @earendil-works/pi-coding-agent` needs **only** + `registry.npmjs.org` — no `pi.dev` egress at all, a strictly smaller and + more auditable allow-list. A live msb network probe found + `registry.npmjs.org` reachable while `pi.dev` was blocked by this + deployment's egress policy / TLS-inspecting proxy in every sandbox tested + — this kit's design avoids depending on a host that isn't already + provisioned for, mirroring the same reasoning that keeps the sibling + `prime-agent` kit off `app.primeintellect.ai`'s live install.sh. + +**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**: + +- `pi.dev` — see "Install method" above +- `github.com` / `*.githubusercontent.com` — no native-module postinstall + step exists for this package (verified: its published npm registry + metadata declares no preinstall/postinstall lifecycle script), so unlike + `openchamber`'s `better-sqlite3` dependency, this kit needs no + release-asset hosts + +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 (`preinstall`/`postinstall`) for this package and its transitive + dependencies at install time — it does **not** vet or sandbox the code that + runs when the installed `pi` binary is later **invoked**. A compromised + package does not need a postinstall hook; it can run arbitrary code with + full agent-user privilege on `pi`'s first invocation. The ephemeral, + single-tenant sandbox (no host filesystem, proxied/allow-listed egress) + remains the real containment boundary — the same threat model every + sibling kit in this family documents. +- **CA-bundle handling is load-bearing, not defensive**: pi is a plain + npm-installed Node CLI making HTTPS requests, and `NODE_EXTRA_CA_CERTS` + only *appends* to Node's built-in trust roots — it needs the sandbox's + proxy CA explicitly, or an inspecting-proxy environment fails closed with + `SELF_SIGNED_CERT_IN_CHAIN`. The block is adapted from `openchamber`'s own + script (same structure and trust rationale, plus decode-failure surfacing + for a malformed `PROXY_CA_CERT_B64`). The proxy CA is read from the + `PROXY_CA_CERT_B64` environment variable (base64-encoded PEM) — the same + variable name `openchamber`/`paseo` already read; this kit only reads it, + never sets or requires it. + +## 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. 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/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..14e6e608 --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/files/home/pi-coding-agent-install.sh @@ -0,0 +1,188 @@ +#!/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. +# +# 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 (verified live: it is not +# shell-injectable regardless — the value is always passed as a single quoted +# argv word, never re-parsed by the shell — but the format check itself was a +# silent no-op against exactly the strings it was meant to catch). 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 + +# Explicit, unprivileged per-user npm prefix — deliberately NOT pi's own +# installer's prefix-detection-with-$HOME/.local-fallback logic (pi's own +# installer tries the system global prefix first and only falls back to +# $HOME/.local if that isn't writable). An explicit, unconditional prefix is +# more auditable and idempotent in a sandbox startup script: it does not +# depend on probing whether the default global prefix happens to be writable +# this boot (which can vary by base image / prior kit ordering), and it +# lands packages in the same destination directory openchamber's own +# convention uses ($HOME/.npm-global) — though not via the same MECHANISM: +# openchamber sets the prefix with an `npm_config_prefix` env-var export, +# while this script passes `--prefix` on the npm command line instead (see +# the comment at the npm install call below for why the CLI flag, not the +# env var, is the correct choice here). This is an intentional divergence +# from pi's own installer, not an oversight. +NPM_PREFIX="$HOME/.npm-global" +case ":$PATH:" in *":$NPM_PREFIX/bin:"*) : ;; *) PATH="$NPM_PREFIX/bin:$PATH" ;; esac +export PATH + +# --- Install pi if it isn't present yet (idempotent). ----------------------- +if ! command -v pi >/dev/null 2>&1; then + # Build a CA bundle for Node's HTTPS requests (both npm's own tarball + # download here, and later pi's own LLM-provider calls at runtime). + # NODE_EXTRA_CA_CERTS *appends* to Node's built-in roots, which lack both + # the sandbox proxy CA and any HTTPS-inspection CA (e.g. Zscaler). This + # block is ADAPTED from openchamber's own script (same PROXY_CA_CERT_B64 + # decode + system-bundle-append + NODE_EXTRA_CA_CERTS-export structure and + # trust rationale), with one deliberate improvement: a failed base64 decode + # is surfaced with a clear error instead of silently producing an + # incomplete bundle (openchamber's own block swallows that same decode + # failure via `2>/dev/null` — not fixed here, since that's a pre-existing, + # separately-tracked concern in a different kit). + _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>/tmp/pi-coding-agent-ca-decode.err; 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 /tmp/pi-coding-agent-ca-decode.err)" >&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" + + mkdir -p "$NPM_PREFIX" + # `--prefix` on the command line (below), not just an npm_config_prefix + # env-var export: verified live that some base images persistently export + # NPM_CONFIG_PREFIX (uppercase) — npm's config precedence reads that over + # a lowercase npm_config_prefix export, so setting only the env var here + # can silently install into whatever the base image's own uppercase + # variable points at instead of this kit's intended per-user prefix. The + # CLI flag has the highest precedence and always wins regardless of what + # the base image has already exported. + + # The standard install path pi's own installer itself runs — NOT the + # experimental PI_EXPERIMENTAL=1 managed-install mode (unstable upstream, + # could change or break without notice). --ignore-scripts: verified + # against the published npm registry metadata that + # @earendil-works/pi-coding-agent declares NO preinstall/postinstall + # lifecycle script, so this flag has nothing to skip for the top-level + # package — it still blocks any transitive dependency's install-time + # hooks. NOTE (residual risk, see spec.yaml header): --ignore-scripts does + # NOT vet or sandbox code that runs when `pi` is later INVOKED; a + # compromised package can still run arbitrary code on first `pi` execution + # with full agent-user privilege. The sandbox itself remains the real + # containment boundary. + _pkg="@earendil-works/pi-coding-agent" + [ "$_pi_version" != "latest" ] && _pkg="${_pkg}@${_pi_version}" + + _log="$(mktemp "${TMPDIR:-/tmp}/pi-coding-agent-install.XXXXXX.log" 2>/dev/null || true)" + [ -n "$_log" ] || _log="/tmp/pi-coding-agent-install.$$.log" + + # --fetch-timeout bounds how long a hanging (not merely erroring) + # connection to the registry can block sandbox startup — verified live, + # e.g. a firewall that silently drops packets rather than refusing the + # connection outright. --fetch-retries=0: this is a STARTUP-phase script + # that runs on every boot, not a one-shot manual install — retrying against + # a systemic failure just multiplies the timeout for no benefit, since the + # NEXT sandbox start already retries naturally. Fail fast instead. + 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 + +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/spec.yaml b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml new file mode 100644 index 00000000..604a2e7b --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml @@ -0,0 +1,203 @@ +# 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. +# +# INSTALL METHOD — npm registry, NOT pi.dev/install.sh: pi.dev publishes a +# curl-pipe-to-shell installer, but this kit deliberately does NOT fetch it. +# Two independent reasons, both verified live this session: +# 1. pi.dev's installer is a MUTABLE live script whose logic can change +# without notice, and it runs an INTERACTIVE Homebrew/apt/apk/standalone- +# Node preflight path when Node/npm are missing — meaningless in a +# non-interactive startup script. Installing Node itself is out of scope +# for this kit (the base image is expected to already carry Node, the +# same assumption openchamber makes for its own base image). +# 2. `npm install -g @earendil-works/pi-coding-agent` needs ONLY +# registry.npmjs.org — no pi.dev egress at all. A live msb network probe +# (both a bare demo sandbox and an acq-provisioned one) found +# registry.npmjs.org reachable in both cases, while pi.dev was refused in +# the bare sandbox and blocked by this deployment's Zscaler TLS-inspecting +# proxy in the other (neither result implicates this kit's own design — +# both are the deny-by-default egress model and the host's own CA-trust +# chain working as intended on hosts that have not been given a route to +# pi.dev) — a real signal that depending on pi.dev directly adds egress +# risk this kit does not need to take on. This mirrors the exact +# reasoning that made the sibling prime-agent kit avoid depending on +# app.primeintellect.ai's live install.sh for its own bundle-fetch step. +# pi.dev is therefore DELIBERATELY ABSENT from caps.network.allow below. +# 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 — do not allow-list it speculatively now. +# +# WHY NOT `--ignore-scripts` LEAVES ANY RESIDUAL RISK: verified against the +# published npm registry metadata for @earendil-works/pi-coding-agent — its +# package.json declares NO preinstall/postinstall lifecycle script, so +# --ignore-scripts (which this kit's install script also passes, matching +# pi's own installer) has nothing to skip; there is no native-module +# postinstall step here the way openchamber's better-sqlite3 dependency has, +# so this kit needs none of openchamber's github.com/*.githubusercontent.com +# release-asset allow-list entries. IMPORTANT residual-risk note: +# --ignore-scripts blocks npm LIFECYCLE hooks (preinstall/postinstall) for +# this package and its transitive deps — it does NOT vet or sandbox the code +# that runs when the installed `pi` binary is later INVOKED. A compromised +# @earendil-works/pi-coding-agent package does not need a postinstall hook at +# all; it can put a payload directly in the code path that runs on `pi`'s +# first invocation, with full agent-user privilege. --ignore-scripts removes +# the install-time hook vector only, not the runtime one — the ephemeral, +# single-tenant sandbox (no host FS, proxied/allow-listed egress) remains the +# real containment boundary, exactly as documented for every sibling kit's +# threat model. +# +# VERSION PIN: PI_CODING_AGENT_VERSION defaults to a specific, reviewed +# release (see pi-coding-agent-install.sh) 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. +# +# CA-bundle: pi is a plain npm-installed Node CLI that will make HTTPS +# requests (both during its own npm install, and later to whichever LLM +# provider API it's configured against). It needs the EXACT SAME +# NODE_EXTRA_CA_CERTS bundle pattern as openchamber/paseo/prime-agent — proxy +# CA + system trust store concatenated, because NODE_EXTRA_CA_CERTS only +# APPENDS to Node's built-in roots and an inspecting proxy's CA is not in +# either set on its own. This is load-bearing, not defensive: the +# SELF_SIGNED_CERT_IN_CHAIN failure mode this guards against has already been +# verified live for prime-agent's own npm install path, and this session's own +# live probe against a Zscaler-inspected host reproduced the analogous +# "unable to get local issuer certificate" failure directly. See +# pi-coding-agent-install.sh, which ADAPTS the block from openchamber's own +# script (same structure and trust rationale, with decode-failure surfacing +# added — see that script's comment for the full diff). The proxy CA itself +# is read from the PROXY_CA_CERT_B64 environment variable (base64-encoded +# PEM) — the same variable name openchamber/paseo already read; not declared +# under this kit's own `environment:` block below because it is supplied by +# whatever injects a proxy CA into the sandbox (e.g. the zscaler-ca-certificate +# kit's own mechanism, or the operator's environment), not by this kit +# itself — this kit only ever reads it, never sets or requires it. +# +# MODEL PROVIDER CONFIG IS DELIBERATELY DEFERRED, not merely out of scope: an +# earlier draft of this kit also shipped a models.json install step and a +# hand-written placeholder catalog with an empty models[] array. 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). 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` (matching the npm package name), +# not 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' +# names (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. +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 (see the header's "INSTALL + # METHOD" note) and NOT any github.com/*.githubusercontent.com + # release-asset host (no native module, no postinstall script — see + # the header's "WHY NOT --ignore-scripts" note): this kit's egress + # surface is intentionally the smallest in the family. + - registry.npmjs.org + +files: + # Lifecycle script. Mode 0755 (executable). + - 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. From cae153b3913997c475978918f18d6cdf098919e3 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Mon, 31 Aug 2026 10:26:36 -0400 Subject: [PATCH 2/5] fix(patterns): address adversarial review findings on pi-coding-agent kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register pi-coding-agent (and the previously-missing prime-agent) in acq-kits/README.md's top-level kit table — it was registered in kits.yaml but never listed in the human-facing index. - Use mktemp for the CA-decode-failure error log, matching the install log's own collision-avoidance in the same script (was a hardcoded /tmp path; not exploitable in this kit's single-tenant threat model, but an unjustified inconsistency within one script). - Tighten the --ignore-scripts residual-risk comments in spec.yaml and the install script: verified every transitive dependency individually (not just the top-level package) has no lifecycle script, and note that --ignore-scripts blocks lifecycle hooks only, not npm's ordinary bin-linking step. Found via two parallel adversarial review passes against PR #399 (one targeting schema/repo-convention drift, one targeting the shell script itself) that assumed every existing claim was wrong until independently re-verified. All fixes re-verified live on msb (fresh install, idempotent re-run, CA-decode-failure now showing the mktemp'd filename) and via validate-kits.py --strict, scan_unsafe_shell.py, shellcheck, and markdownlint-cli2. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/README.md | 2 ++ .../files/home/pi-coding-agent-install.sh | 28 ++++++++++++------- .../acq-kits/pi-coding-agent/spec.yaml | 23 ++++++++------- 3 files changed, 33 insertions(+), 20 deletions(-) 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/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 index 14e6e608..87afb363 100755 --- 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 @@ -116,12 +116,17 @@ if ! command -v pi >/dev/null 2>&1; then mkdir -p "$(dirname "$_ca")" : > "$_ca" if [ -n "${PROXY_CA_CERT_B64:-}" ]; then - if ! printf %s "$PROXY_CA_CERT_B64" | base64 -d >> "$_ca" 2>/tmp/pi-coding-agent-ca-decode.err; then + # Same predictable-/tmp-path avoidance as the install log below (mktemp, + # not a hardcoded name) — this script creates two files under /tmp in a + # single run and both should meet the same standard, not just one. + _ca_err="$(mktemp "${TMPDIR:-/tmp}/pi-coding-agent-ca-decode.XXXXXX.err" 2>/dev/null || true)" + [ -n "$_ca_err" ] || _ca_err="/tmp/pi-coding-agent-ca-decode.$$.err" + if ! printf %s "$PROXY_CA_CERT_B64" | base64 -d >> "$_ca" 2>"$_ca_err"; 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 /tmp/pi-coding-agent-ca-decode.err)" >&2 + echo "pi-coding-agent: PROXY_CA_CERT_B64 failed to base64-decode; proxy CA NOT added to the bundle (see $_ca_err)" >&2 fi fi [ -f /etc/ssl/certs/ca-certificates.crt ] && cat /etc/ssl/certs/ca-certificates.crt >> "$_ca" @@ -141,14 +146,17 @@ if ! command -v pi >/dev/null 2>&1; then # experimental PI_EXPERIMENTAL=1 managed-install mode (unstable upstream, # could change or break without notice). --ignore-scripts: verified # against the published npm registry metadata that - # @earendil-works/pi-coding-agent declares NO preinstall/postinstall - # lifecycle script, so this flag has nothing to skip for the top-level - # package — it still blocks any transitive dependency's install-time - # hooks. NOTE (residual risk, see spec.yaml header): --ignore-scripts does - # NOT vet or sandbox code that runs when `pi` is later INVOKED; a - # compromised package can still run arbitrary code on first `pi` execution - # with full agent-user privilege. The sandbox itself remains the real - # containment boundary. + # @earendil-works/pi-coding-agent AND every one of its transitive + # dependencies (checked individually, not assumed) declare NO + # preinstall/postinstall lifecycle script, so this flag has nothing to + # skip anywhere in the dependency tree for this exact pinned version. + # NOTE (residual risk, see spec.yaml header): --ignore-scripts blocks + # lifecycle HOOKS only — npm still performs ordinary package-manager + # bookkeeping regardless (e.g. linking the package's declared `bin` entry + # onto PATH), and none of that vets the code that runs when `pi` is later + # INVOKED. A compromised package can still run arbitrary code on first + # `pi` execution with full agent-user privilege. The sandbox itself + # remains the real containment boundary. _pkg="@earendil-works/pi-coding-agent" [ "$_pi_version" != "latest" ] && _pkg="${_pkg}@${_pi_version}" diff --git a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml index 604a2e7b..6edc04f1 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml +++ b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml @@ -53,16 +53,19 @@ # with a live-tested justification — do not allow-list it speculatively now. # # WHY NOT `--ignore-scripts` LEAVES ANY RESIDUAL RISK: verified against the -# published npm registry metadata for @earendil-works/pi-coding-agent — its -# package.json declares NO preinstall/postinstall lifecycle script, so -# --ignore-scripts (which this kit's install script also passes, matching -# pi's own installer) has nothing to skip; there is no native-module -# postinstall step here the way openchamber's better-sqlite3 dependency has, -# so this kit needs none of openchamber's github.com/*.githubusercontent.com -# release-asset allow-list entries. IMPORTANT residual-risk note: -# --ignore-scripts blocks npm LIFECYCLE hooks (preinstall/postinstall) for -# this package and its transitive deps — it does NOT vet or sandbox the code -# that runs when the installed `pi` binary is later INVOKED. A compromised +# published npm registry metadata for @earendil-works/pi-coding-agent AND +# every one of its transitive dependencies (checked individually, not +# assumed) — none declares a preinstall/postinstall lifecycle script for this +# exact pinned version, so --ignore-scripts (which this kit's install script +# also passes, matching pi's own installer) has nothing to skip anywhere in +# the dependency tree; there is no native-module postinstall step here the +# way openchamber's better-sqlite3 dependency has, so this kit needs none of +# openchamber's github.com/*.githubusercontent.com release-asset allow-list +# entries. IMPORTANT residual-risk note: --ignore-scripts blocks npm +# LIFECYCLE HOOKS only — npm still performs ordinary package-manager +# bookkeeping regardless of this flag (e.g. linking the package's declared +# `bin` entry onto PATH), and none of that vets the code that runs when the +# installed `pi` binary is later INVOKED. A compromised # @earendil-works/pi-coding-agent package does not need a postinstall hook at # all; it can put a payload directly in the code path that runs on `pi`'s # first invocation, with full agent-user privilege. --ignore-scripts removes From 50ddcae6fcd888490d0944ca2a3e2b72fe94d31b Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Fri, 11 Sep 2026 14:42:51 -0400 Subject: [PATCH 3/5] fix(patterns): address pi-coding-agent review findings (PATH, CA persistence, missing kit files) Fixes 5 findings from a PR review (patterns#399): 1. pi was not reachable after install: npm prefix moved from $HOME/.npm-global (not on the guest's default PATH) to $HOME/.local (is). Verified live via a bare, fresh `sh -c` probe with zero PATH massaging. 2. The runtime CA-bundle claim was false: an install-time-only NODE_EXTRA_CA_CERTS export never reached a `pi` process launched later in a separate shell. Fixed with a thin wrapper at $HOME/.local/bin/pi that rebuilds the bundle and re-execs the real binary on every invocation, itself rebuilt every boot (not just on a fresh install) so it self-heals an already-installed sandbox. Found and fixed two additional bugs during implementation, live: an idempotent-re-run regression (lost track of the real binary path once npm's symlink was replaced by the wrapper -- fixed with a sidecar cache file) and a symlink-follow clobber (naively redirecting into the still-live npm symlink truncated the real binary itself -- fixed by removing the symlink first). Verified across 3 consecutive boots with no regressions. 3. scripts/verify, TROUBLESHOOTING.md, and docs/decisions/ were missing despite being mandatory per acq-kits/README.md. Added, matching the sibling kits' conventions -- scripts/verify's step 4 specifically probes `command -v pi` through a bare sh -c with no PATH injection, the exact check that would have caught finding 1. Live-ran the full offline + RUN_ACQ=1 suite: 7/7 live checks pass. 4. Design rationale was duplicated across spec.yaml, README.md, and the install script. Consolidated into docs/decisions/ (two ADRs, matching the sibling kits' one-decision-per-file convention); the other three now cite it instead of restating it. 5. A leaked CA-decode temp file. Fixed: a single shared install log now serves both the CA-decode-failure and npm-install-failure paths. Verified live -- no leftover files after any boot. Verified: validate-kits.py --strict (clean for pi-coding-agent; the one failure in the run is __pycache__, a pre-existing unrelated repo issue), sh -n + shellcheck + scan_unsafe_shell.py (all clean), markdownlint-cli2 (0 issues), and the full live msb regression suite. Co-authored-by: OpenCode Agent --- .../acq-kits/pi-coding-agent/README.md | 66 ++-- .../pi-coding-agent/TROUBLESHOOTING.md | 188 ++++++++++ .../ca-bundle-wrapper-not-env-var.md | 129 +++++++ .../decisions/local-prefix-not-npm-global.md | 108 ++++++ .../files/home/pi-coding-agent-install.sh | 204 ++++++++--- .../acq-kits/pi-coding-agent/scripts/verify | 330 ++++++++++++++++++ .../acq-kits/pi-coding-agent/spec.yaml | 100 ++---- 7 files changed, 951 insertions(+), 174 deletions(-) create mode 100644 integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md create mode 100644 integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/ca-bundle-wrapper-not-env-var.md create mode 100644 integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/local-prefix-not-npm-global.md create mode 100755 integrations/isolation/acq-kits/pi-coding-agent/scripts/verify diff --git a/integrations/isolation/acq-kits/pi-coding-agent/README.md b/integrations/isolation/acq-kits/pi-coding-agent/README.md index ba05156b..c46e364f 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/README.md +++ b/integrations/isolation/acq-kits/pi-coding-agent/README.md @@ -42,8 +42,13 @@ environment variable, or `~/.pi/agent/auth.json` per - **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/.npm-global`). Idempotent: - a no-op if `pi` is already on `PATH`. + 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 @@ -52,19 +57,11 @@ 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. Two reasons, both verified live: - -1. That script is a **mutable, live** script whose logic can change without - notice, and it runs an **interactive** Node/npm-install preflight path - that is meaningless in a non-interactive sandbox startup script. -2. `npm install -g @earendil-works/pi-coding-agent` needs **only** - `registry.npmjs.org` — no `pi.dev` egress at all, a strictly smaller and - more auditable allow-list. A live msb network probe found - `registry.npmjs.org` reachable while `pi.dev` was blocked by this - deployment's egress policy / TLS-inspecting proxy in every sandbox tested - — this kit's design avoids depending on a host that isn't already - provisioned for, mirroring the same reasoning that keeps the sibling - `prime-agent` kit off `app.primeintellect.ai`'s live install.sh. +fetch it — see [`docs/decisions/`](docs/decisions/) for the full rationale +(a mutable live script with an interactive Node/npm preflight path, and +`pi.dev` egress this kit's allow-list deliberately omits). 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 @@ -78,14 +75,8 @@ 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**: - -- `pi.dev` — see "Install method" above -- `github.com` / `*.githubusercontent.com` — no native-module postinstall - step exists for this package (verified: its published npm registry - metadata declares no preinstall/postinstall lifecycle script), so unlike - `openchamber`'s `better-sqlite3` dependency, this kit needs no - release-asset hosts +Deliberately **absent** (see `docs/decisions/` 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. @@ -109,25 +100,16 @@ Model-provider egress (`api.gsa.usai.gov`) is **not** here — that is the 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 (`preinstall`/`postinstall`) for this package and its transitive - dependencies at install time — it does **not** vet or sandbox the code that - runs when the installed `pi` binary is later **invoked**. A compromised - package does not need a postinstall hook; it can run arbitrary code with - full agent-user privilege on `pi`'s first invocation. The ephemeral, - single-tenant sandbox (no host filesystem, proxied/allow-listed egress) - remains the real containment boundary — the same threat model every - sibling kit in this family documents. -- **CA-bundle handling is load-bearing, not defensive**: pi is a plain - npm-installed Node CLI making HTTPS requests, and `NODE_EXTRA_CA_CERTS` - only *appends* to Node's built-in trust roots — it needs the sandbox's - proxy CA explicitly, or an inspecting-proxy environment fails closed with - `SELF_SIGNED_CERT_IN_CHAIN`. The block is adapted from `openchamber`'s own - script (same structure and trust rationale, plus decode-failure surfacing - for a malformed `PROXY_CA_CERT_B64`). The proxy CA is read from the - `PROXY_CA_CERT_B64` environment variable (base64-encoded PEM) — the same - variable name `openchamber`/`paseo` already read; this kit only reads it, - never sets or requires it. +- **`--ignore-scripts` is not a complete mitigation.** See + [`docs/decisions/`](docs/decisions/) for the full residual-risk analysis; + in short, it blocks npm lifecycle hooks at install time, not code that runs + when `pi` is later **invoked** — 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 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..04eae696 --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md @@ -0,0 +1,188 @@ +# 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` — see "Naming/PATH note" below. + +## `pi` not found after install (regression watch) + +**Symptoms:** the startup log shows +`pi-coding-agent: pi is installed and on PATH (...)`, but a fresh +`acq exec -- sh -c 'command -v pi'` (a bare, non-login shell, no +PATH massaging) comes back empty, or `pi --version` fails to run. + +**Cause (the underlying bug this entry documents, now fixed).** An earlier +version of this kit installed with an explicit `--prefix "$HOME/.npm-global"` +— a directory that is **not** on the sandbox's default `PATH` for a fresh +non-login shell (the same reason the sibling `openchamber`/`paseo` kits' own +`scripts/verify` have to inject `$HOME/.npm-global/bin` into every probe's +`PATH` — see their `in_sbx` helpers). The startup script's *own* process could +still find `pi` (it prepends the prefix's `bin/` to its own `PATH` before +installing), so the install would report success, yet a brand-new shell in a +different session — e.g. the human actually typing `pi` at a TUI prompt — +would not see it. `scripts/verify` step 4 specifically probes `command -v pi` +through a bare `sh -c` for exactly this reason: that is the check that would +have caught this class of bug before it shipped. + +The fix moved the npm prefix to `$HOME/.local` (already first on the guest's +default `PATH` — no PATH injection needed) and, for the `pi` binary itself, +uses a small wrapper at `$HOME/.local/bin/pi` that also rebuilds +`NODE_EXTRA_CA_CERTS` on every invocation (see +[`docs/decisions/`](docs/decisions/) for why a wrapper rather than an +install-time `export`). + +**If you ever see this again (a future regression), check:** + +```bash +acq exec -- sh -c 'command -v pi' # should print a path with NO PATH prepending +acq exec -- sh -c 'echo $PATH' # confirm $HOME/.local/bin is on it +acq exec -- sh -c 'ls -la ~/.local/bin/pi' # confirm the wrapper (or bin link) is there +acq exec -- sh -c 'npm prefix -g' # confirm the effective npm prefix +``` + +If `pi` resolves only with an explicit `PATH=$HOME/.local/bin:$HOME/.npm-global/bin:...` +prepended, the install has regressed to a non-default-PATH prefix — recreate +the sandbox with the current kit, or file an issue. + +## 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 /tmp/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 (and, per the wrapper design in +`docs/decisions/`, needs to be **rebuilt on every invocation**, not just at +install time, because an `export` inside the install script's own process +does not persist to a separately-launched `pi` process later). + +**Fix:** confirm the wrapper is rebuilding the CA bundle at invocation time, +not just at install: + +```bash +acq exec -- sh -c 'cat ~/.local/bin/pi' # should reference NODE_EXTRA_CA_CERTS on every run, not a one-time export +``` + +If it doesn't, the wrapper has regressed to install-time-only CA export; 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..1faee19a --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/docs/decisions/ca-bundle-wrapper-not-env-var.md @@ -0,0 +1,129 @@ +# 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. A wrapper that runs the derivation at invocation is the only point + in the lifecycle where both inputs are known to be current. +- **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 runtime rebuild 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 index 87afb363..9aa1424c 100755 --- 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 @@ -8,7 +8,9 @@ # 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. +# 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 @@ -81,57 +83,79 @@ case "$_pi_version" in ;; esac -# Explicit, unprivileged per-user npm prefix — deliberately NOT pi's own -# installer's prefix-detection-with-$HOME/.local-fallback logic (pi's own -# installer tries the system global prefix first and only falls back to -# $HOME/.local if that isn't writable). An explicit, unconditional prefix is -# more auditable and idempotent in a sandbox startup script: it does not -# depend on probing whether the default global prefix happens to be writable -# this boot (which can vary by base image / prior kit ordering), and it -# lands packages in the same destination directory openchamber's own -# convention uses ($HOME/.npm-global) — though not via the same MECHANISM: -# openchamber sets the prefix with an `npm_config_prefix` env-var export, -# while this script passes `--prefix` on the npm command line instead (see -# the comment at the npm install call below for why the CLI flag, not the -# env var, is the correct choice here). This is an intentional divergence -# from pi's own installer, not an oversight. -NPM_PREFIX="$HOME/.npm-global" -case ":$PATH:" in *":$NPM_PREFIX/bin:"*) : ;; *) PATH="$NPM_PREFIX/bin:$PATH" ;; esac -export PATH +# Single shared log for this boot's run — the CA-decode-failure path and the +# npm-install-failure path both append to it, rather than each creating its +# own separate mktemp file (an earlier version of this script did that; the +# second file was created even on a fully successful boot, as an empty, +# never-read leftover under /tmp — mktemp creates the file immediately on +# call, not lazily on first write). +_log="$(mktemp "${TMPDIR:-/tmp}/pi-coding-agent-install.XXXXXX.log" 2>/dev/null || true)" +[ -n "$_log" ] || _log="/tmp/pi-coding-agent-install.$$.log" -# --- Install pi if it isn't present yet (idempotent). ----------------------- -if ! command -v pi >/dev/null 2>&1; then - # Build a CA bundle for Node's HTTPS requests (both npm's own tarball - # download here, and later pi's own LLM-provider calls at runtime). - # NODE_EXTRA_CA_CERTS *appends* to Node's built-in roots, which lack both - # the sandbox proxy CA and any HTTPS-inspection CA (e.g. Zscaler). This - # block is ADAPTED from openchamber's own script (same PROXY_CA_CERT_B64 - # decode + system-bundle-append + NODE_EXTRA_CA_CERTS-export structure and - # trust rationale), with one deliberate improvement: a failed base64 decode - # is surfaced with a clear error instead of silently producing an - # incomplete bundle (openchamber's own block swallows that same decode - # failure via `2>/dev/null` — not fixed here, since that's a pre-existing, - # separately-tracked concern in a different kit). - _ca="$HOME/.local/state/pi-coding-agent/ca-bundle.pem" - mkdir -p "$(dirname "$_ca")" - : > "$_ca" - if [ -n "${PROXY_CA_CERT_B64:-}" ]; then - # Same predictable-/tmp-path avoidance as the install log below (mktemp, - # not a hardcoded name) — this script creates two files under /tmp in a - # single run and both should meet the same standard, not just one. - _ca_err="$(mktemp "${TMPDIR:-/tmp}/pi-coding-agent-ca-decode.XXXXXX.err" 2>/dev/null || true)" - [ -n "$_ca_err" ] || _ca_err="/tmp/pi-coding-agent-ca-decode.$$.err" - if ! printf %s "$PROXY_CA_CERT_B64" | base64 -d >> "$_ca" 2>"$_ca_err"; 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 $_ca_err)" >&2 - fi +# Explicit, unprivileged per-user npm prefix. $HOME/.local, specifically — +# NOT $HOME/.npm-global (the convention openchamber/paseo use), and NOT pi's +# own installer's prefix-detection-with-$HOME/.local-fallback logic either +# (pi's own installer tries the system global prefix first and only falls +# back to $HOME/.local if that isn't writable). +# +# WHY $HOME/.local, specifically: it is on this kit's target base images' +# DEFAULT guest PATH already (confirmed live). $HOME/.npm-global/bin is NOT +# — which is exactly why openchamber's and paseo's own startup scripts each +# have to prepend it to PATH themselves, and why their `scripts/verify` +# probes have to inject the same prepend into every `sh -c` they run inside +# the sandbox (see either sibling's `in_sbx()` helper and its own comment on +# why: a bare, later `sh -c` — e.g. the one a real user's interactive shell +# runs, or `acq exec … -- pi` — gets NEITHER kit's PATH prepend, since that +# prepend is this PROCESS-local, not persisted anywhere a root-owned +# /etc/profile.d or similar could pick up as the non-root agent user; see +# openchamber-start.sh's own "SCOPE LIMIT" comment for the full explanation +# of why there is no agent-user-safe way to persist it). By choosing a +# prefix whose bin/ directory the base image ALREADY has on PATH, this kit +# sidesteps that whole class of problem instead of working around it: no +# process-local PATH prepend is needed here, in `scripts/verify`, or for a +# user's own later shell — `pi` is reachable the same way regardless of who +# invokes it or when. See docs/decisions/local-prefix-not-npm-global.md. +# +# An explicit, unconditional prefix (rather than probing at runtime whether +# some OTHER prefix happens to be writable) is also more auditable and +# idempotent in a sandbox startup script — this remains true independent of +# the .local-vs-.npm-global choice above, and is why this script still does +# not use pi's own installer's runtime-detection logic. +NPM_PREFIX="$HOME/.local" + +# Build a CA bundle for Node's HTTPS requests — UNCONDITIONALLY, on every +# boot, not only when pi is freshly installed: (1) npm's own tarball download +# below needs it during an install, and (2) the exported wrapper this script +# writes near the end needs an ALWAYS-CURRENT bundle path for pi's own later +# LLM-provider calls, since a proxy CA could change between boots and a stale +# bundle would fail closed with a confusing TLS error rather than a clear one. +# NODE_EXTRA_CA_CERTS *appends* to Node's built-in roots, which lack both the +# sandbox proxy CA and any HTTPS-inspection CA (e.g. Zscaler). This block is +# ADAPTED from openchamber's own script (same PROXY_CA_CERT_B64 decode + +# system-bundle-append + NODE_EXTRA_CA_CERTS-export structure and trust +# rationale), with one deliberate improvement: a failed base64 decode is +# surfaced with a clear error (into the shared $_log above, not a second, +# separate temp file) instead of silently producing an incomplete bundle +# (openchamber's own block swallows that same decode failure via +# `2>/dev/null` — not fixed here, since that's a pre-existing, separately- +# tracked concern in a different kit). +_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 - [ -f /etc/ssl/certs/ca-certificates.crt ] && cat /etc/ssl/certs/ca-certificates.crt >> "$_ca" - [ -s "$_ca" ] && export NODE_EXTRA_CA_CERTS="$_ca" +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: verified live that some base images persistently export @@ -160,9 +184,6 @@ if ! command -v pi >/dev/null 2>&1; then _pkg="@earendil-works/pi-coding-agent" [ "$_pi_version" != "latest" ] && _pkg="${_pkg}@${_pi_version}" - _log="$(mktemp "${TMPDIR:-/tmp}/pi-coding-agent-install.XXXXXX.log" 2>/dev/null || true)" - [ -n "$_log" ] || _log="/tmp/pi-coding-agent-install.$$.log" - # --fetch-timeout bounds how long a hanging (not merely erroring) # connection to the registry can block sandbox startup — verified live, # e.g. a firewall that silently drops packets rather than refusing the @@ -184,6 +205,85 @@ if ! command -v pi >/dev/null 2>&1; then 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: that export +# is scoped to THIS SCRIPT's own process. It does nothing for the `pi` +# process a user launches later, in a completely separate shell — an +# install-time env-var export cannot reach a process that starts after this +# script has already exited. Concretely, without this wrapper, behind the +# Zscaler-inspecting proxy this kit is designed for: first boot installs +# `pi` successfully (the CA bundle IS exported for that process, so npm's own +# HTTPS calls work), then the user runs `pi` themselves and its first HTTPS +# call to the LLM provider fails with SELF_SIGNED_CERT_IN_CHAIN / +# UNABLE_TO_GET_ISSUER_CERT_LOCALLY — the exact failure this kit's CA-bundle +# logic exists to prevent, just not actually prevented for the process that +# matters. See docs/decisions/ca-bundle-wrapper-not-env-var.md. +# +# _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 (reproduced live: Node then fails 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" <&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 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..c82fd9bf --- /dev/null +++ b/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify @@ -0,0 +1,330 @@ +#!/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"; } +warn() { printf ' \033[33mWARN\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 is (being) changed to use `$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 +# (review finding: "a verify that probes `command -v pi` through a bare `sh -c` +# would have caught finding 1" — the old-prefix/no-wrapper 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). ------------------------------ +# The review finding this file exists to address: "the live-tested behaviours +# (idempotent re-run, bad-version rejection, old-Node rejection, CA-decode +# logging) exist only in the PR description, so a regression in any of them +# goes undetected." 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 command -v pi >/dev/null 2>&1; then :; fi # no-op; keeps shellcheck quiet about unused fn style below +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 the review asked for: "a verify that probes +# `command -v pi` through a bare `sh -c` would have caught finding 1." 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 "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 /tmp/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" +# REGRESSION GUARD: the review's "idempotent re-run" behaviour. 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 `command -v pi >/dev/null 2>&1 || { …install… }`; 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" +# REGRESSION GUARD: the review's "bad-version rejection" behaviour. 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" +# REGRESSION GUARD: the review's "CA-decode failure logging" behaviour. Force a +# base64 decode failure with a garbage PROXY_CA_CERT_B64 and confirm the script +# prints its own explicit warning rather than silently shipping an incomplete +# trust bundle. This does not require pi to be missing — the CA-bundle block +# only runs on an actual install attempt, so pair it with a throwaway version +# override to force that branch without disturbing the real install. +ca_out="$(in_sbx 'PROXY_CA_CERT_B64="not-valid-base64!!!" PI_CODING_AGENT_VERSION=latest sh -c "rm -f \$(command -v pi) 2>/dev/null; true"; 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" + ;; + *) + warn "did not observe the decode-failure message (output: $ca_out) — this step forcibly removes pi first to re-trigger the install branch; if pi failed to reinstall afterward, re-run scripts/verify" + ;; +esac +# Restore: make sure pi is present again after step 8's forced-removal probe, +# so a KEEP=1 inspection run isn't left in a misleading state. +in_sbx 'command -v pi >/dev/null 2>&1 || sh /home/agent/pi-coding-agent-install.sh' >/dev/null 2>&1 || true + +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 index 6edc04f1..9592a750 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml +++ b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml @@ -25,80 +25,18 @@ # openchamber / paseo, which each publish a browser-UI port). Publishes NO # ports. # -# INSTALL METHOD — npm registry, NOT pi.dev/install.sh: pi.dev publishes a -# curl-pipe-to-shell installer, but this kit deliberately does NOT fetch it. -# Two independent reasons, both verified live this session: -# 1. pi.dev's installer is a MUTABLE live script whose logic can change -# without notice, and it runs an INTERACTIVE Homebrew/apt/apk/standalone- -# Node preflight path when Node/npm are missing — meaningless in a -# non-interactive startup script. Installing Node itself is out of scope -# for this kit (the base image is expected to already carry Node, the -# same assumption openchamber makes for its own base image). -# 2. `npm install -g @earendil-works/pi-coding-agent` needs ONLY -# registry.npmjs.org — no pi.dev egress at all. A live msb network probe -# (both a bare demo sandbox and an acq-provisioned one) found -# registry.npmjs.org reachable in both cases, while pi.dev was refused in -# the bare sandbox and blocked by this deployment's Zscaler TLS-inspecting -# proxy in the other (neither result implicates this kit's own design — -# both are the deny-by-default egress model and the host's own CA-trust -# chain working as intended on hosts that have not been given a route to -# pi.dev) — a real signal that depending on pi.dev directly adds egress -# risk this kit does not need to take on. This mirrors the exact -# reasoning that made the sibling prime-agent kit avoid depending on -# app.primeintellect.ai's live install.sh for its own bundle-fetch step. -# pi.dev is therefore DELIBERATELY ABSENT from caps.network.allow below. -# 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 — do not allow-list it speculatively now. -# -# WHY NOT `--ignore-scripts` LEAVES ANY RESIDUAL RISK: verified against the -# published npm registry metadata for @earendil-works/pi-coding-agent AND -# every one of its transitive dependencies (checked individually, not -# assumed) — none declares a preinstall/postinstall lifecycle script for this -# exact pinned version, so --ignore-scripts (which this kit's install script -# also passes, matching pi's own installer) has nothing to skip anywhere in -# the dependency tree; there is no native-module postinstall step here the -# way openchamber's better-sqlite3 dependency has, so this kit needs none of -# openchamber's github.com/*.githubusercontent.com release-asset allow-list -# entries. IMPORTANT residual-risk note: --ignore-scripts blocks npm -# LIFECYCLE HOOKS only — npm still performs ordinary package-manager -# bookkeeping regardless of this flag (e.g. linking the package's declared -# `bin` entry onto PATH), and none of that vets the code that runs when the -# installed `pi` binary is later INVOKED. A compromised -# @earendil-works/pi-coding-agent package does not need a postinstall hook at -# all; it can put a payload directly in the code path that runs on `pi`'s -# first invocation, with full agent-user privilege. --ignore-scripts removes -# the install-time hook vector only, not the runtime one — the ephemeral, -# single-tenant sandbox (no host FS, proxied/allow-listed egress) remains the -# real containment boundary, exactly as documented for every sibling kit's -# threat model. -# -# VERSION PIN: PI_CODING_AGENT_VERSION defaults to a specific, reviewed -# release (see pi-coding-agent-install.sh) 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. -# -# CA-bundle: pi is a plain npm-installed Node CLI that will make HTTPS -# requests (both during its own npm install, and later to whichever LLM -# provider API it's configured against). It needs the EXACT SAME -# NODE_EXTRA_CA_CERTS bundle pattern as openchamber/paseo/prime-agent — proxy -# CA + system trust store concatenated, because NODE_EXTRA_CA_CERTS only -# APPENDS to Node's built-in roots and an inspecting proxy's CA is not in -# either set on its own. This is load-bearing, not defensive: the -# SELF_SIGNED_CERT_IN_CHAIN failure mode this guards against has already been -# verified live for prime-agent's own npm install path, and this session's own -# live probe against a Zscaler-inspected host reproduced the analogous -# "unable to get local issuer certificate" failure directly. See -# pi-coding-agent-install.sh, which ADAPTS the block from openchamber's own -# script (same structure and trust rationale, with decode-failure surfacing -# added — see that script's comment for the full diff). The proxy CA itself -# is read from the PROXY_CA_CERT_B64 environment variable (base64-encoded -# PEM) — the same variable name openchamber/paseo already read; not declared -# under this kit's own `environment:` block below because it is supplied by -# whatever injects a proxy CA into the sandbox (e.g. the zscaler-ca-certificate -# kit's own mechanism, or the operator's environment), not by this kit -# itself — this kit only ever reads it, never sets or requires it. +# The install-method choice (npm registry, not pi.dev/install.sh), the +# --ignore-scripts residual-risk analysis, the npm-install prefix choice +# ($HOME/.local, not $HOME/.npm-global) and the CA-bundle wrapper design are +# each documented ONCE, in docs/decisions/ (a lighter-weight per-decision ADR +# format, matching openchamber's/paseo's own convention), not restated here +# — an earlier revision of this kit wrote the same rationale out three times +# (this header, README.md, and the install script's own comments), and a +# review flagged that a future change (bumping the pin, adding pi.dev, +# wiring models.json) would have to be edited consistently in all three +# places to stay accurate. See docs/decisions/ for the full "why"; this +# header and the install script's comments now cite it rather than +# duplicating it. # # MODEL PROVIDER CONFIG IS DELIBERATELY DEFERRED, not merely out of scope: an # earlier draft of this kit also shipped a models.json install step and a @@ -148,15 +86,17 @@ caps: # 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 (see the header's "INSTALL - # METHOD" note) and NOT any github.com/*.githubusercontent.com - # release-asset host (no native module, no postinstall script — see - # the header's "WHY NOT --ignore-scripts" note): this kit's egress - # surface is intentionally the smallest in the family. + # sufficient). Deliberately NOT pi.dev and NOT any + # github.com/*.githubusercontent.com release-asset host — see + # docs/decisions/ for why. This kit's egress surface is intentionally + # the smallest in the family. - registry.npmjs.org files: - # Lifecycle script. Mode 0755 (executable). + # Lifecycle script. Mode 0755 (executable). Builds a per-user npm prefix, + # a per-boot CA trust bundle, and the runtime wrapper that carries that + # bundle into `pi`'s own invocations — see docs/decisions/ for why each of + # these exists. - path: /home/agent/pi-coding-agent-install.sh mode: "0755" source: files/home/pi-coding-agent-install.sh From 5c47a63229528d8d2fa1e03e5d7ddd240003591e Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 17 Sep 2026 15:29:00 -0400 Subject: [PATCH 4/5] fix(patterns): address pi-coding-agent follow-up review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to basiliskus's six September 15 follow-up asks on PR #399: - Trim duplicated design rationale from spec.yaml/README.md/install script comments down to a single citation of docs/decisions/, matching the header's own stated intent (finding: remaining duplicated rationale). - Add scripts/verify step 4b: confirms `pi` resolves to this kit's CA-bundle wrapper (exports NODE_EXTRA_CA_CERTS), not npm's bare bin symlink. Step 4 alone only proved something resolved on PATH, not that it was the wrapper (finding: add a verify step for the wrapper). - Simplify verify step 8 (CA-decode failure): the CA-bundle block runs unconditionally on every boot, so the forced-removal-and- restore dance around the decode-failure probe was unnecessary. Fixed a real severity bug in the same step: a missing decode- failure message now reports `bad`, not `warn` (finding: simplify verify step 8). - Move the per-boot install log from a random mktemp path under /tmp to a fixed, truncated-per-run $HOME/.local/state/pi-coding-agent/install.log, and fix two diagnostics (TROUBLESHOOTING.md, scripts/verify step 5) that still referenced the old, now-nonexistent glob pattern (finding: fixed log path). - Guard the wrapper's NODE_EXTRA_CA_CERTS export on `[ -s "$_ca" ]`, matching the same guard already used earlier in the script, so a boot with no proxy CA and no system bundle does not point at a useless zero-length cert file. Switch the wrapper to `exec "$_pi_real"` rather than hardcoding `exec node`, preserving whatever interpreter the real binary's own shebang declares (finding: fixed wrapper nits). - Remove review-thread narration ("the review finding this file exists to address", "REGRESSION GUARD: the review's...") from code comments in favor of durable prose describing the same regression class, per this repo's own durable-references convention (finding: drop review narration comments). Verified: sh -n, shellcheck --severity=warning (0 issues on both scripts), scripts/scan_unsafe_shell.py (0 errors/warnings), validate-kits.py --strict (pi-coding-agent OK), markdownlint-cli2 (0 issues), and a live RUN_ACQ=1 scripts/verify end-to-end run on a real msb sandbox — all 8 live steps pass, including the new step 4b and the corrected step 8 severity. Co-authored-by: OpenCode Agent --- .../acq-kits/pi-coding-agent/README.md | 6 +- .../pi-coding-agent/TROUBLESHOOTING.md | 71 +++------ .../ca-bundle-wrapper-not-env-var.md | 5 +- .../files/home/pi-coding-agent-install.sh | 139 +++++++++--------- .../acq-kits/pi-coding-agent/scripts/verify | 106 +++++++------ .../acq-kits/pi-coding-agent/spec.yaml | 43 ++---- 6 files changed, 166 insertions(+), 204 deletions(-) diff --git a/integrations/isolation/acq-kits/pi-coding-agent/README.md b/integrations/isolation/acq-kits/pi-coding-agent/README.md index c46e364f..b112c108 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/README.md +++ b/integrations/isolation/acq-kits/pi-coding-agent/README.md @@ -117,8 +117,10 @@ 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. Every file, script, and log line in this kit -says `pi-coding-agent`, never bare `pi`, for the same reason. +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 diff --git a/integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md index 04eae696..a903d769 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/pi-coding-agent/TROUBLESHOOTING.md @@ -5,47 +5,9 @@ Failure modes specific to the pi-coding-agent kit, which installs (`@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` — see "Naming/PATH note" below. - -## `pi` not found after install (regression watch) - -**Symptoms:** the startup log shows -`pi-coding-agent: pi is installed and on PATH (...)`, but a fresh -`acq exec -- sh -c 'command -v pi'` (a bare, non-login shell, no -PATH massaging) comes back empty, or `pi --version` fails to run. - -**Cause (the underlying bug this entry documents, now fixed).** An earlier -version of this kit installed with an explicit `--prefix "$HOME/.npm-global"` -— a directory that is **not** on the sandbox's default `PATH` for a fresh -non-login shell (the same reason the sibling `openchamber`/`paseo` kits' own -`scripts/verify` have to inject `$HOME/.npm-global/bin` into every probe's -`PATH` — see their `in_sbx` helpers). The startup script's *own* process could -still find `pi` (it prepends the prefix's `bin/` to its own `PATH` before -installing), so the install would report success, yet a brand-new shell in a -different session — e.g. the human actually typing `pi` at a TUI prompt — -would not see it. `scripts/verify` step 4 specifically probes `command -v pi` -through a bare `sh -c` for exactly this reason: that is the check that would -have caught this class of bug before it shipped. - -The fix moved the npm prefix to `$HOME/.local` (already first on the guest's -default `PATH` — no PATH injection needed) and, for the `pi` binary itself, -uses a small wrapper at `$HOME/.local/bin/pi` that also rebuilds -`NODE_EXTRA_CA_CERTS` on every invocation (see -[`docs/decisions/`](docs/decisions/) for why a wrapper rather than an -install-time `export`). - -**If you ever see this again (a future regression), check:** - -```bash -acq exec -- sh -c 'command -v pi' # should print a path with NO PATH prepending -acq exec -- sh -c 'echo $PATH' # confirm $HOME/.local/bin is on it -acq exec -- sh -c 'ls -la ~/.local/bin/pi' # confirm the wrapper (or bin link) is there -acq exec -- sh -c 'npm prefix -g' # confirm the effective npm prefix -``` - -If `pi` resolves only with an explicit `PATH=$HOME/.local/bin:$HOME/.npm-global/bin:...` -prepended, the install has regressed to a non-default-PATH prefix — recreate -the sandbox with the current kit, or file an issue. +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 @@ -119,7 +81,7 @@ no benefit; the *next* boot retries naturally. **Fix:** check the install log and egress: ```bash -acq exec -- sh -c 'cat /tmp/pi-coding-agent-install.*.log 2>/dev/null | tail -n 40' +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}"' ``` @@ -160,20 +122,25 @@ 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 (and, per the wrapper design in -`docs/decisions/`, needs to be **rebuilt on every invocation**, not just at -install time, because an `export` inside the install script's own process -does not persist to a separately-launched `pi` process later). - -**Fix:** confirm the wrapper is rebuilding the CA bundle at invocation time, -not just at install: +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 reference NODE_EXTRA_CA_CERTS on every run, not a one-time export +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 it doesn't, the wrapper has regressed to install-time-only CA export; see -`docs/decisions/` for the intended design and file an issue. +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 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 index 1faee19a..9a3f1376 100644 --- 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 @@ -89,8 +89,9 @@ become the real thing," not a one-off invention for this kit. 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. A wrapper that runs the derivation at invocation is the only point - in the lifecycle where both inputs are known to be current. + 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 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 index 9aa1424c..867b1dfd 100755 --- 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 @@ -61,12 +61,14 @@ fi # 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 (verified live: it is not -# shell-injectable regardless — the value is always passed as a single quoted -# argv word, never re-parsed by the shell — but the format check itself was a -# silent no-op against exactly the strings it was meant to catch). The second -# `case` below closes that gap by explicitly rejecting any character outside -# a safe version-string charset. +# "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]*) @@ -83,38 +85,25 @@ case "$_pi_version" in ;; esac -# Single shared log for this boot's run — the CA-decode-failure path and the -# npm-install-failure path both append to it, rather than each creating its -# own separate mktemp file (an earlier version of this script did that; the -# second file was created even on a fully successful boot, as an empty, -# never-read leftover under /tmp — mktemp creates the file immediately on -# call, not lazily on first write). -_log="$(mktemp "${TMPDIR:-/tmp}/pi-coding-agent-install.XXXXXX.log" 2>/dev/null || true)" -[ -n "$_log" ] || _log="/tmp/pi-coding-agent-install.$$.log" +# Single shared, FIXED log path for this boot's run — the CA-decode-failure +# path and the npm-install-failure path both append to it, rather than each +# creating its own separate mktemp file: a second, per-path mktemp file would +# exist even on a fully successful boot as an empty, never-read leftover +# under /tmp, since mktemp creates the file immediately on call, not lazily +# on first write. A FIXED path (rather than mktemp) avoids that same problem +# one level up, since this block runs unconditionally near the top of the +# script, not only inside an error path. Truncated (not appended-to-forever) +# at the start of every run, so the log always reflects only the CURRENT +# boot's run, never a prior one's. +_log="$HOME/.local/state/pi-coding-agent/install.log" +mkdir -p "$(dirname "$_log")" +: > "$_log" -# Explicit, unprivileged per-user npm prefix. $HOME/.local, specifically — -# NOT $HOME/.npm-global (the convention openchamber/paseo use), and NOT pi's -# own installer's prefix-detection-with-$HOME/.local-fallback logic either -# (pi's own installer tries the system global prefix first and only falls -# back to $HOME/.local if that isn't writable). -# -# WHY $HOME/.local, specifically: it is on this kit's target base images' -# DEFAULT guest PATH already (confirmed live). $HOME/.npm-global/bin is NOT -# — which is exactly why openchamber's and paseo's own startup scripts each -# have to prepend it to PATH themselves, and why their `scripts/verify` -# probes have to inject the same prepend into every `sh -c` they run inside -# the sandbox (see either sibling's `in_sbx()` helper and its own comment on -# why: a bare, later `sh -c` — e.g. the one a real user's interactive shell -# runs, or `acq exec … -- pi` — gets NEITHER kit's PATH prepend, since that -# prepend is this PROCESS-local, not persisted anywhere a root-owned -# /etc/profile.d or similar could pick up as the non-root agent user; see -# openchamber-start.sh's own "SCOPE LIMIT" comment for the full explanation -# of why there is no agent-user-safe way to persist it). By choosing a -# prefix whose bin/ directory the base image ALREADY has on PATH, this kit -# sidesteps that whole class of problem instead of working around it: no -# process-local PATH prepend is needed here, in `scripts/verify`, or for a -# user's own later shell — `pi` is reachable the same way regardless of who -# invokes it or when. See docs/decisions/local-prefix-not-npm-global.md. +# Explicit, unprivileged per-user npm prefix: $HOME/.local, not +# $HOME/.npm-global (the openchamber/paseo convention) and not pi's own +# installer's runtime prefix-detection-with-fallback logic. See +# docs/decisions/local-prefix-not-npm-global.md for why $HOME/.local +# specifically. # # An explicit, unconditional prefix (rather than probing at runtime whether # some OTHER prefix happens to be writable) is also more auditable and @@ -158,13 +147,13 @@ fi 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: verified live that some base images persistently export - # NPM_CONFIG_PREFIX (uppercase) — npm's config precedence reads that over - # a lowercase npm_config_prefix export, so setting only the env var here - # can silently install into whatever the base image's own uppercase - # variable points at instead of this kit's intended per-user prefix. The - # CLI flag has the highest precedence and always wins regardless of what - # the base image has already exported. + # env-var export: some base images persistently export NPM_CONFIG_PREFIX + # (uppercase) — npm's config precedence reads that over a lowercase + # npm_config_prefix export, so setting only the env var here can silently + # install into whatever the base image's own uppercase variable points at + # instead of this kit's intended per-user prefix. The CLI flag has the + # highest precedence and always wins regardless of what the base image has + # already exported. # The standard install path pi's own installer itself runs — NOT the # experimental PI_EXPERIMENTAL=1 managed-install mode (unstable upstream, @@ -185,12 +174,12 @@ if ! command -v pi >/dev/null 2>&1; then [ "$_pi_version" != "latest" ] && _pkg="${_pkg}@${_pi_version}" # --fetch-timeout bounds how long a hanging (not merely erroring) - # connection to the registry can block sandbox startup — verified live, - # e.g. a firewall that silently drops packets rather than refusing the - # connection outright. --fetch-retries=0: this is a STARTUP-phase script - # that runs on every boot, not a one-shot manual install — retrying against - # a systemic failure just multiplies the timeout for no benefit, since the - # NEXT sandbox start already retries naturally. Fail fast instead. + # connection to the registry can block sandbox startup, e.g. a firewall + # that silently drops packets rather than refusing the connection + # outright. --fetch-retries=0: this is a STARTUP-phase script that runs on + # every boot, not a one-shot manual install — retrying against a systemic + # failure just multiplies the timeout for no benefit, since the NEXT + # sandbox start already retries naturally. Fail fast instead. if npm install -g --ignore-scripts --prefix "$NPM_PREFIX" \ --fetch-timeout=30000 --fetch-retries=0 \ "$_pkg" >>"$_log" 2>&1; then @@ -213,18 +202,9 @@ fi # 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: that export -# is scoped to THIS SCRIPT's own process. It does nothing for the `pi` -# process a user launches later, in a completely separate shell — an -# install-time env-var export cannot reach a process that starts after this -# script has already exited. Concretely, without this wrapper, behind the -# Zscaler-inspecting proxy this kit is designed for: first boot installs -# `pi` successfully (the CA bundle IS exported for that process, so npm's own -# HTTPS calls work), then the user runs `pi` themselves and its first HTTPS -# call to the LLM provider fails with SELF_SIGNED_CERT_IN_CHAIN / -# UNABLE_TO_GET_ISSUER_CERT_LOCALLY — the exact failure this kit's CA-bundle -# logic exists to prevent, just not actually prevented for the process that -# matters. See docs/decisions/ca-bundle-wrapper-not-env-var.md. +# 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 @@ -262,21 +242,40 @@ if [ -n "$_pi_real" ] && [ -e "$_pi_real" ]; then # 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 (reproduced live: Node then fails to - # parse the clobbered file as JS). Remove the symlink first so the - # redirection creates a fresh regular file at $_pi_bin instead. + # 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" < "$_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. diff --git a/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify b/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify index c82fd9bf..99647580 100755 --- a/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify +++ b/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify @@ -86,18 +86,18 @@ trap cleanup EXIT INT TERM # `$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 is (being) changed to use `$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. +# 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 -# (review finding: "a verify that probes `command -v pi` through a bare `sh -c` -# would have caught finding 1" — the old-prefix/no-wrapper 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. +# 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 ;; @@ -127,13 +127,13 @@ else fi # --- Offline source checks (no sandbox needed). ------------------------------ -# The review finding this file exists to address: "the live-tested behaviours +# These regression guards exist because the live-tested behaviours # (idempotent re-run, bad-version rejection, old-Node rejection, CA-decode -# logging) exist only in the PR description, so a regression in any of them -# goes undetected." 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 +# 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)" @@ -152,7 +152,6 @@ if grep -Eq 'base64 -d' "$INSTALL_SCRIPT" && grep -Eq 'failed to base64-decode' 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 command -v pi >/dev/null 2>&1; then :; fi # no-op; keeps shellcheck quiet about unused fn style below 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 @@ -228,15 +227,15 @@ while :; do 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 the review asked for: "a verify that probes -# `command -v pi` through a bare `sh -c` would have caught finding 1." 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. +# 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)" @@ -244,6 +243,22 @@ 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" +# REGRESSION GUARD: 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 — exactly the +# most severe finding this kit's wrapper exists to fix, previously with no +# regression guard of its own. +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. @@ -253,17 +268,17 @@ 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 /tmp/pi-coding-agent-install.*.log 2>/dev/null || true' | sed '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" -# REGRESSION GUARD: the review's "idempotent re-run" behaviour. 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 `command -v pi >/dev/null 2>&1 || { …install… }`; with pi already present +# 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 +# `command -v pi >/dev/null 2>&1 || { …install… }`; 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)" @@ -285,10 +300,9 @@ else fi info "7. Bad-version rejection — a malformed PI_CODING_AGENT_VERSION is refused, not installed" -# REGRESSION GUARD: the review's "bad-version rejection" behaviour. 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. +# 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 @@ -305,24 +319,20 @@ case "$bad_ver_out" in esac info "8. CA-decode failure is surfaced, not silently swallowed" -# REGRESSION GUARD: the review's "CA-decode failure logging" behaviour. Force a -# base64 decode failure with a garbage PROXY_CA_CERT_B64 and confirm the script -# prints its own explicit warning rather than silently shipping an incomplete -# trust bundle. This does not require pi to be missing — the CA-bundle block -# only runs on an actual install attempt, so pair it with a throwaway version -# override to force that branch without disturbing the real install. -ca_out="$(in_sbx 'PROXY_CA_CERT_B64="not-valid-base64!!!" PI_CODING_AGENT_VERSION=latest sh -c "rm -f \$(command -v pi) 2>/dev/null; true"; PROXY_CA_CERT_B64="not-valid-base64!!!" sh /home/agent/pi-coding-agent-install.sh 2>&1')" +# 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" ;; *) - warn "did not observe the decode-failure message (output: $ca_out) — this step forcibly removes pi first to re-trigger the install branch; if pi failed to reinstall afterward, re-run scripts/verify" + bad "a malformed PROXY_CA_CERT_B64 was NOT surfaced with a decode-failure message (output: $ca_out)" ;; esac -# Restore: make sure pi is present again after step 8's forced-removal probe, -# so a KEEP=1 inspection run isn't left in a misleading state. -in_sbx 'command -v pi >/dev/null 2>&1 || sh /home/agent/pi-coding-agent-install.sh' >/dev/null 2>&1 || true info "Summary" [ "$fail" -eq 0 ] && printf ' \033[32mAll checks passed.\033[0m\n' || printf ' \033[31mSome checks failed — see notes above.\033[0m\n' diff --git a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml index 9592a750..0651729f 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml +++ b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml @@ -25,43 +25,26 @@ # openchamber / paseo, which each publish a browser-UI port). Publishes NO # ports. # -# The install-method choice (npm registry, not pi.dev/install.sh), the -# --ignore-scripts residual-risk analysis, the npm-install prefix choice -# ($HOME/.local, not $HOME/.npm-global) and the CA-bundle wrapper design are -# each documented ONCE, in docs/decisions/ (a lighter-weight per-decision ADR -# format, matching openchamber's/paseo's own convention), not restated here -# — an earlier revision of this kit wrote the same rationale out three times -# (this header, README.md, and the install script's own comments), and a -# review flagged that a future change (bumping the pin, adding pi.dev, -# wiring models.json) would have to be edited consistently in all three -# places to stay accurate. See docs/decisions/ for the full "why"; this -# header and the install script's comments now cite it rather than -# duplicating it. +# Design rationale (install-method choice, --ignore-scripts residual risk, +# npm prefix choice, CA-bundle wrapper) lives in docs/decisions/, not here. # -# MODEL PROVIDER CONFIG IS DELIBERATELY DEFERRED, not merely out of scope: an -# earlier draft of this kit also shipped a models.json install step and a -# hand-written placeholder catalog with an empty models[] array. 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). 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. +# 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` (matching the npm package name), -# not 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' -# names (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. +# 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 From fc1b5ca78e2a72ecf45d5ca07e5ea159e7ced18f Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Mon, 21 Sep 2026 13:46:31 -0400 Subject: [PATCH 5/5] fix(pi-coding-agent): address basiliskus's 4th-round follow-up review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to basiliskus's September 17 re-review on PR #399: - Rationale still duplicated in the install script: trimmed the npm-prefix, --ignore-scripts, and CA-bundle-wrapper comment blocks (pi-coding-agent-install.sh:102-130, :158-172, :197-217) down to one-line pointers, matching what spec.yaml/README.md already do. 295 -> 245 lines. - Two dangling pointers: spec.yaml and README.md pointed at docs/decisions/ for the install-method choice (npm vs pi.dev/install.sh) and the --ignore-scripts residual risk, but neither existing ADR (ca-bundle-wrapper-not-env-var.md, local-prefix-not-npm-global.md) actually covers either. Rather than add a third ADR for reasoning that already lives correctly in one place, dropped the dangling pointers and pointed to the actual location instead (the install script's own comments; README.md's "Install method"/"Supply-chain notes" sections). - Narration remnants: removed the mktemp-history restatement (pi-coding-agent-install.sh:88-97), "not fixed here, since that's a pre-existing, separately-tracked concern" (:128-130), and "previously with no regression guard of its own" (scripts/ verify:247-254). - Stale bits from 5c47a63: ca-bundle-wrapper-not-env-var.md:122's "this decision's runtime rebuild" -> "boot-time wrapper rewrite" (the wrapper is rewritten on every boot, nothing is rebuilt "at invocation"); removed scripts/verify's now-unused warn() helper (step 8 already FAILs outright, nothing calls warn() anymore); the step 6 comment now describes the guard's actual current shape (`if ! command -v pi >/dev/null 2>&1; then …fi`), not the `|| { … }` shape from an earlier revision. Verified: sh -n and shellcheck --severity=warning (0 issues) on both modified scripts; scripts/scan_unsafe_shell.py (0 errors/warnings); validate-kits.py --strict (pi-coding-agent OK); markdownlint-cli2 (0 issues); a live RUN_ACQ=1 scripts/verify end-to-end run against a real sandbox — all 8 live steps pass, including the wrapper check (4b), the idempotent re-run (6), the bad-version rejection (7), and the CA-decode-failure surfacing (8). Co-authored-by: OpenCode Agent --- .../acq-kits/pi-coding-agent/README.md | 20 ++--- .../ca-bundle-wrapper-not-env-var.md | 2 +- .../files/home/pi-coding-agent-install.sh | 78 ++++--------------- .../acq-kits/pi-coding-agent/scripts/verify | 19 ++--- .../acq-kits/pi-coding-agent/spec.yaml | 20 +++-- 5 files changed, 45 insertions(+), 94 deletions(-) diff --git a/integrations/isolation/acq-kits/pi-coding-agent/README.md b/integrations/isolation/acq-kits/pi-coding-agent/README.md index b112c108..b60f6da5 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/README.md +++ b/integrations/isolation/acq-kits/pi-coding-agent/README.md @@ -57,10 +57,10 @@ 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 — see [`docs/decisions/`](docs/decisions/) for the full rationale -(a mutable live script with an interactive Node/npm preflight path, and -`pi.dev` egress this kit's allow-list deliberately omits). The short version: -`npm install -g @earendil-works/pi-coding-agent` needs only +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 @@ -75,7 +75,7 @@ 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 `docs/decisions/` for why): `pi.dev`, +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 @@ -100,11 +100,11 @@ Model-provider egress (`api.gsa.usai.gov`) is **not** here — that is the 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.** See - [`docs/decisions/`](docs/decisions/) for the full residual-risk analysis; - in short, it blocks npm lifecycle hooks at install time, not code that runs - when `pi` is later **invoked** — the ephemeral, single-tenant sandbox - remains the real containment boundary. +- **`--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 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 index 9a3f1376..d6b24068 100644 --- 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 @@ -120,7 +120,7 @@ become the real thing," not a one-off invention for this kit. ## Links - `../files/home/pi-coding-agent-install.sh` — the install-time CA-bundle - block this decision's runtime rebuild parallels. + 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 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 index 867b1dfd..f37aff2d 100755 --- 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 @@ -85,49 +85,18 @@ case "$_pi_version" in ;; esac -# Single shared, FIXED log path for this boot's run — the CA-decode-failure -# path and the npm-install-failure path both append to it, rather than each -# creating its own separate mktemp file: a second, per-path mktemp file would -# exist even on a fully successful boot as an empty, never-read leftover -# under /tmp, since mktemp creates the file immediately on call, not lazily -# on first write. A FIXED path (rather than mktemp) avoids that same problem -# one level up, since this block runs unconditionally near the top of the -# script, not only inside an error path. Truncated (not appended-to-forever) -# at the start of every run, so the log always reflects only the CURRENT -# boot's run, never a prior one's. +# 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: $HOME/.local, not -# $HOME/.npm-global (the openchamber/paseo convention) and not pi's own -# installer's runtime prefix-detection-with-fallback logic. See -# docs/decisions/local-prefix-not-npm-global.md for why $HOME/.local -# specifically. -# -# An explicit, unconditional prefix (rather than probing at runtime whether -# some OTHER prefix happens to be writable) is also more auditable and -# idempotent in a sandbox startup script — this remains true independent of -# the .local-vs-.npm-global choice above, and is why this script still does -# not use pi's own installer's runtime-detection logic. +# 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, on every -# boot, not only when pi is freshly installed: (1) npm's own tarball download -# below needs it during an install, and (2) the exported wrapper this script -# writes near the end needs an ALWAYS-CURRENT bundle path for pi's own later -# LLM-provider calls, since a proxy CA could change between boots and a stale -# bundle would fail closed with a confusing TLS error rather than a clear one. -# NODE_EXTRA_CA_CERTS *appends* to Node's built-in roots, which lack both the -# sandbox proxy CA and any HTTPS-inspection CA (e.g. Zscaler). This block is -# ADAPTED from openchamber's own script (same PROXY_CA_CERT_B64 decode + -# system-bundle-append + NODE_EXTRA_CA_CERTS-export structure and trust -# rationale), with one deliberate improvement: a failed base64 decode is -# surfaced with a clear error (into the shared $_log above, not a second, -# separate temp file) instead of silently producing an incomplete bundle -# (openchamber's own block swallows that same decode failure via -# `2>/dev/null` — not fixed here, since that's a pre-existing, separately- -# tracked concern in a different kit). +# 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" @@ -148,38 +117,19 @@ 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) — npm's config precedence reads that over a lowercase - # npm_config_prefix export, so setting only the env var here can silently - # install into whatever the base image's own uppercase variable points at - # instead of this kit's intended per-user prefix. The CLI flag has the - # highest precedence and always wins regardless of what the base image has - # already exported. + # (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 (unstable upstream, - # could change or break without notice). --ignore-scripts: verified - # against the published npm registry metadata that - # @earendil-works/pi-coding-agent AND every one of its transitive - # dependencies (checked individually, not assumed) declare NO - # preinstall/postinstall lifecycle script, so this flag has nothing to - # skip anywhere in the dependency tree for this exact pinned version. - # NOTE (residual risk, see spec.yaml header): --ignore-scripts blocks - # lifecycle HOOKS only — npm still performs ordinary package-manager - # bookkeeping regardless (e.g. linking the package's declared `bin` entry - # onto PATH), and none of that vets the code that runs when `pi` is later - # INVOKED. A compromised package can still run arbitrary code on first - # `pi` execution with full agent-user privilege. The sandbox itself - # remains the real containment boundary. + # 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 how long a hanging (not merely erroring) - # connection to the registry can block sandbox startup, e.g. a firewall - # that silently drops packets rather than refusing the connection - # outright. --fetch-retries=0: this is a STARTUP-phase script that runs on - # every boot, not a one-shot manual install — retrying against a systemic - # failure just multiplies the timeout for no benefit, since the NEXT - # sandbox start already retries naturally. Fail fast instead. + # --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 diff --git a/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify b/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify index 99647580..83143d3f 100755 --- a/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify +++ b/integrations/isolation/acq-kits/pi-coding-agent/scripts/verify @@ -63,7 +63,6 @@ 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"; } -warn() { printf ' \033[33mWARN\033[0m %s\n' "$1"; } info() { printf '\n\033[1m%s\033[0m\n' "$1"; } cleanup() { @@ -244,14 +243,12 @@ else fi info "4b. \`pi\` is the CA-bundle wrapper, not npm's bare bin link" -# REGRESSION GUARD: 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 — exactly the -# most severe finding this kit's wrapper exists to fix, previously with no -# regression guard of its own. +# 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" @@ -278,8 +275,8 @@ info "6. Idempotent re-run — running the install script again is a clean no-op # 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 -# `command -v pi >/dev/null 2>&1 || { …install… }`; with pi already present -# this should be fast and silent about installing. +# `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 diff --git a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml index 0651729f..8b921697 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml +++ b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml @@ -25,8 +25,12 @@ # openchamber / paseo, which each publish a browser-UI port). Publishes NO # ports. # -# Design rationale (install-method choice, --ignore-scripts residual risk, -# npm prefix choice, CA-bundle wrapper) lives in docs/decisions/, not here. +# 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 @@ -71,15 +75,15 @@ caps: # 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 - # docs/decisions/ for why. This kit's egress surface is intentionally - # the smallest in the family. + # 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, - # a per-boot CA trust bundle, and the runtime wrapper that carries that - # bundle into `pi`'s own invocations — see docs/decisions/ for why each of - # these exists. + # 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