diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index b37b417a..9d4e2cbe 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -25,6 +25,7 @@ not agent behavior. (Behavioral patterns live in `skills/`, `prompts/`, etc.) | [`openchamber/`](openchamber/) | Run OpenChamber, a browser UI for OpenCode, inside the sandbox alongside the terminal TUI. Opt-in (see its parity note). | | [`opencode/`](opencode/) | Install the OpenCode v2 terminal AI coding agent from pinned, SHA-256-verified npm platform tarballs. Agent-harness kit; no ports. | | [`paseo/`](paseo/) | Self-host the [Paseo](https://github.com/getpaseo/paseo) daemon + browser web UI (for coding agents) inside the sandbox on one port. Opt-in; agent-generic wrapper, no shared TUI session. | +| [`opendesign/`](opendesign/) | Run [OpenDesign](https://github.com/nexu-io/open-design) headlessly inside the sandbox as a browser design workspace, reusing the existing OpenCode CLI and ACQ-injected USAi credentials. Opt-in. | | [`prime-agent/`](prime-agent/) | Install [PrimeIntellect-ai/prime-agent](https://github.com/PrimeIntellect-ai/prime-agent), a terminal AI coding assistant with an embedded IPython tool. Opt-in; TUI, no ports. | | [`pi-coding-agent/`](pi-coding-agent/) | Install [earendil-works/pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-coding-agent`), a plain terminal AI coding agent (read/write/edit/bash tools only). Opt-in; TUI, no ports. | diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 242f8cbf..979c289d 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -98,6 +98,26 @@ kits: ONE port. The daemon runs unsecured (no PASEO_PASSWORD); the sandbox is the security boundary and the port is host loopback only. + opendesign: + backends: [sbx, msb] + parity: | + Opt-in headless browser design workspace for OpenDesign. Written entirely + in the neutral vocabulary with no backend shortcut or extras: the single + published container port (7456, serving the OpenDesign daemon/API + web UI) + is expressed with the neutral top-level `publishedPorts` list; the 10G + persistent OpenDesign data/source volume is expressed with the neutral + `volumes` list; and the never-exiting startup supervisor is marked with the + neutral `background: true` flag. acq's kit translation maps publishedPorts, + volumes, files, environment, and background startup to the active backend's + native primitives. The kit installs a pinned OpenDesign source checkout with + a kit-managed Node 24 runtime, runs the daemon unsecured + (OD_DISABLE_API_AUTH=1), seeds OpenDesign to use the existing `opencode` + CLI, and leaves model credentials to acq + the usai-provider kit. The + daemon binds guest loopback only and a kit-managed Node relay publishes it + on the guest network address, because OpenDesign gates some routes on a + loopback peer address; that relay is a plain byte forwarder with no + backend-specific assumptions. + oci-engine: backends: [sbx, msb] parity: | @@ -133,8 +153,8 @@ kits: (prime-agent is a TUI, not a web UI) — unlike openchamber/paseo. SKELETON 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 + (files/home/prime-agent-config/models.json) are intentionally deferred to + follow-up work for the prime-agent kit. Model-provider egress is the usai-provider kit's job, not this one. pi-coding-agent: diff --git a/integrations/isolation/acq-kits/opendesign/README.md b/integrations/isolation/acq-kits/opendesign/README.md new file mode 100644 index 00000000..aaf7d1a0 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/README.md @@ -0,0 +1,230 @@ +# opendesign (acq mixin kit, `hybrid/v1`) + +Runs [OpenDesign](https://github.com/nexu-io/open-design) headlessly inside an +`acq` sandbox and publishes its browser UI / daemon to the host. OpenDesign is a +local-first design workspace that launches existing agent CLIs. This kit uses the +existing sandbox `opencode`; it does not install OpenCode and does not own model +credentials. + +OpenDesign is exposed on container port **7456**. Internally, the daemon listens +on guest loopback port `17456`, and a small relay publishes it on guest port +`7456` for ACQ port mapping. + +This kit is **opt-in** and is designed to compose with the default GSA kits, +especially `usai-provider`. + +## Quick start + +1. **Set the kit reference** to a full commit SHA of this repo: + + ```bash + export ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=&dir=integrations/isolation/acq-kits/opendesign" + ``` + +2. **Ensure the USAi key is available through acq** if you want the default GSA + OpenCode provider path: + + ```bash + acq secret set-custom -g --host api.gsa.usai.gov --env USAI_API_KEY + ``` + + `usai-provider` configures OpenCode to read `{env:USAI_API_KEY}`. This kit + does not copy that key into OpenDesign storage. + +3. **Start the sandbox with OpenCode:** + + ```bash + acq run opencode /path/to/your/project + ``` + + Leave the terminal open. The OpenDesign daemon starts in the background. + +4. **Open the browser UI.** Find the mapped host port and open it: + + ```bash + acq ports # look up the host port mapped to container 7456 + ``` + + Open `http://localhost:`. On first boot, wait for the + source install and build, then reload. + +## What the kit provisions + +- A pinned OpenDesign source checkout at `open-design-v0.24.0`, verified against + commit `0d3a14c1df6dc5017f3cc3ef05b24558250c220b`. +- A kit-managed Node `24.21.0` runtime under the kit volume, verified by + SHA-256. +- pnpm `10.33.2` through Corepack. +- OpenDesign daemon startup on internal `127.0.0.1:17456` with `--no-open`, + plus a supervised relay that publishes it on guest port `7456` (see + [Security](#security)). +- Root startup initialization that makes the root of the persistent OpenDesign + volume writable by the `agent` user before the daemon supervisor runs. +- OpenDesign state, source checkout, kit-managed Node runtime, Corepack state, + pnpm executables, and pnpm store under `/home/agent/.local/share/opendesign`. +- OpenDesign app-config seeding for `agentId: "opencode"`, + `onboardingCompleted: true`, and local telemetry disabled when no user choice + exists. +- `OPENCODE_CONFIG` pointing at the `usai-provider` OpenCode config, so + OpenDesign-launched runs actually reach USAi (see [Credential + model](#credential-model)). + +The kit deliberately does **not** seed a default model. OpenCode/OpenDesign use +their existing configured defaults. + +This kit builds OpenDesign from source during first boot. That means it runs +OpenDesign's pinned `pnpm install --frozen-lockfile`, package lifecycle scripts, +bootstrap build, and Next.js production build inside the sandbox as the `agent` +user. The source commit, Node archive, pnpm version, and lockfile are pinned, but +this is still a larger first-boot build surface than sibling kits that install a +single published artifact. + +## Credential model + +The intended GSA credential path is: + +```text +acq secret store + -> sandbox env: USAI_API_KEY + -> usai-provider OpenCode config + -> OpenDesign launches opencode + -> opencode reads USAi from its own config/env +``` + +Do not paste model-provider keys into chat. For the core OpenCode/USAi flow, +OpenDesign never needs a copy of the provider key in its `OD_DATA_DIR`. + +One wrinkle makes an extra step necessary. OpenDesign launches agent children +with `OD_SANDBOX_MODE=1`, which rewrites `HOME` and `XDG_CONFIG_HOME` to a +private agent home under `OD_DATA_DIR`. OpenCode resolves its global config from +those variables, so it would look inside +`$OD_DATA_DIR/sandbox/config/opencode/` — where it auto-creates an empty stub — +and never read the `usai-provider` config. With no `usai` provider defined, +OpenCode falls back to its own hosted gateway (`opencode.ai/zen`), which is not +in this kit's egress allowlist, and every run fails with `Cannot connect to API` +after burning its retry budget. + +Startup therefore exports `OPENCODE_CONFIG` (OpenCode's documented absolute-path +override, which survives that rewrite): + +```text +OPENCODE_CONFIG=/home/agent/.config/opencode/opencode.jsonc +``` + +That names a config **path** only. `USAI_API_KEY` stays in the environment and is +resolved by the config's own `{env:USAI_API_KEY}` substitution at run time, so no +key material moves into OpenDesign state. Startup logs which config it selected, +and warns when it finds none. + +If you configure extra OpenDesign media providers in the UI, OpenDesign stores +those settings in its own local data directory. Treat that directory as sensitive +local state. + +## Reaching it from the host + +The kit exposes container port `7456`, declared via neutral `publishedPorts` in +`spec.yaml`. `acq` publishes it to a host loopback port per sandbox. Look up the +mapping: + +```bash +acq ports +# OpenDesign UI: open http://localhost: +``` + +The automatic create-time mapping is the supported host-browser path. Avoid +adding an extra post-hoc fixed-port publish for this kit: depending on backend +implementation, that may dial guest loopback port `7456` and bypass the relay +split (`7456` -> `17456`) that keeps OpenDesign's peer-loopback-gated routes +working. + +## Security + +The daemon runs with `OD_DISABLE_API_AUTH=1` and binds guest `127.0.0.1` only. +This is safe only because the sandbox is the security boundary and the host-side +published port is loopback-only. + +Run this only on a trusted, single-user host. Do not forward the mapped port to a +wider interface. Anyone who can reach the host loopback port can drive the +OpenDesign daemon and the agents it launches. + +`acq` create-time port publishing on msb-style backends dials the sandbox guest +network IP, not guest `127.0.0.1`, so something in the guest must listen there. +The kit does that with a small supervised relay (`~/opendesign-relay.mjs`) that +listens on published guest port `7456` and forwards to the loopback daemon on +internal port `17456` — rather than binding the daemon to `0.0.0.0`. The relay +accepts loopback, the default gateway peer, and optional backend-specific +`OPENDESIGN_RELAY_ALLOWED_PEERS` entries; other guest-network peers are denied +before forwarding. That peer filter is defense-in-depth against accidental guest +network reachability, not the primary security boundary: code running inside the +sandbox is already trusted for this local development posture. Do not rely on the +relay to contain a hostile in-sandbox process, especially on backends where the +guest network is shared across sandboxes. + +That distinction matters functionally, not just cosmetically. OpenDesign gates +several routes on the request **peer** address being loopback +(`requireLocalDaemonRequest`). With the daemon on `0.0.0.0`, every request +arriving from the host carries a guest-network peer, and those routes return +`403 Forbidden` while the rest of the UI works — most visibly Settings → About → +**Export diagnostics**, and `POST /api/strategies/od-next/rollout`. The relay +keeps the peer loopback, so they work. Details and the rejected alternatives are +in +[`docs/decisions/disable-api-auth-loopback-boundary.md`](docs/decisions/disable-api-auth-loopback-boundary.md). + +## HTTPS-inspected networks + +OpenDesign is installed from GitHub, Node.js, and npm. Behind an inspecting +proxy, pair this kit with the `zscaler-ca-certificate` kit so Node/pnpm trust +the inspection CA. The install script appends `PROXY_CA_CERT_B64` only when it +can decode it successfully, then appends the system CA bundle to +`NODE_EXTRA_CA_CERTS` when available. + +## Backend support + +`acq` selects and abstracts the isolation backend. This kit is written entirely +in neutral `hybrid/v1` vocabulary (`caps`, `files`, `commands`, `publishedPorts`, +`volumes`, and `environment`) and uses no backend-specific shortcuts. See +[`kits.yaml`](../kits.yaml) for the parity summary. + +## Validating + +```bash +# Offline gate: schema + file paths + registry + kit-specific invariants. +# Needs python3 + jsonschema + pyyaml; run from this kit directory. +./scripts/verify + +# Lightweight smoke-only mode if Python deps are unavailable. This is not enough +# for review completion because it skips validate-kits.py --strict. +ALLOW_PARTIAL_VERIFY=1 ./scripts/verify + +# Live end-to-end via acq, from a sandbox-capable host. +RUN_ACQ=1 ./scripts/verify + +# Keep the sandbox afterward to inspect it. +KEEP=1 RUN_ACQ=1 ./scripts/verify +``` + +CI for this repo cannot create nested sandboxes, so live verification is a +host-side step before PR review/merge. Treat skipped schema validation as +incomplete unless `ALLOW_PARTIAL_VERIFY=1` was intentionally used for a local +smoke check. + +## Layout + +```text +opendesign/ +├── spec.yaml # kit declaration +├── files/home/ +│ ├── opendesign-install.sh # Node 24 + OpenDesign source install/build +│ ├── opendesign-relay.mjs # guest-network -> loopback publish relay +│ └── opendesign-start.sh # app-config seed + daemon/relay supervisors +├── README.md +├── TROUBLESHOOTING.md +├── scripts/verify # offline + optional live verification +└── docs/decisions/ # design records +``` + +## Design records + +Rationale lives in [`docs/decisions/`](docs/decisions/): source install instead +of Docker, OpenDesign auth disabled behind the ACQ loopback boundary, and +ACQ/OpenCode-owned BYOK credential flow. diff --git a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md new file mode 100644 index 00000000..8600daae --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md @@ -0,0 +1,351 @@ +# Troubleshooting — opendesign acq kit + +Symptoms, causes, and fixes for the OpenDesign headless browser kit. Most +diagnosis is done with `acq exec -- sh -c '…'`. + +OpenDesign state and logs can contain prompts, run metadata, generated file +paths, and local provider/media settings. Do not paste full logs, diagnostics, or +`OD_DATA_DIR` files into public issues or PR comments; redact first or share a +reviewed diagnostics bundle through the approved channel. + +--- + +## Browser UI never loads / connection refused + +Check the port mapping and daemon health: + +```bash +acq ports +acq exec -- sh -c 'curl -fsS http://127.0.0.1:17456/api/daemon/status && echo OK' +``` + +- `/api/daemon/status` returns 200 and the port is mapped: open + `http://localhost:`. If the browser still cannot connect, + the in-guest daemon is healthy but the publish relay is not — see "Host curl + returns Empty reply from server" below. +- `/api/daemon/status` fails: first boot may still be installing/building OpenDesign. + Watch the logs: + + ```bash + acq exec -- sh -c 'tail -n 80 ~/.local/state/opendesign/opendesign-install.log' + acq exec -- sh -c 'tail -n 80 ~/.local/state/opendesign/opendesign-daemon.log' + acq exec -- sh -c 'tail -n 40 ~/.local/state/opendesign/opendesign-relay.log' + ``` + +## First boot takes a long time + +OpenDesign is built from source in the sandbox. First boot downloads a pinned +Node 24 tarball, fetches the OpenDesign source tag, installs pnpm dependencies, +and builds both the daemon and web app. + +Later starts skip the build when the persisted marker matches the pinned +OpenDesign commit, Node version, and pnpm version. + +## No install log and no daemon process + +If `/home/agent/.local/share/opendesign` exists but both logs are missing and no +`node`/`pnpm`/`git` process is running, check persistent volume ownership: + +```bash +acq exec -- sh -c 'stat -c "%U:%G %a %n" /home/agent/.local/share/opendesign /home/agent/.local/state/opendesign' +``` + +The OpenDesign volume must be writable by `agent`. This kit includes a root +startup step that runs before the agent-owned supervisor: + +```bash +chown 1000:1000 /home/agent/.local/share/opendesign /home/agent/.local/state/opendesign +``` + +If the volume remains `root:root 755`, the backend did not run the ownership-init +startup command or the sandbox was created with an older kit version. Recreate +the sandbox with the current kit. + +## Node 24 install fails + +The install script supports Linux `x86_64`/`amd64` and `aarch64`/`arm64` only. +It downloads Node from `nodejs.org` and verifies SHA-256. + +Check: + +```bash +acq exec -- sh -c 'tail -n 80 ~/.local/state/opendesign/opendesign-install.log' +``` + +Common causes: + +- `nodejs.org:443` is blocked by egress policy. +- The sandbox architecture is not x64 or arm64. +- TLS inspection is active but the CA bundle was not installed; pair with + `zscaler-ca-certificate`. + +## pnpm install or build fails + +OpenDesign uses Node `~24` and pnpm `10.33.2`. The kit installs pnpm through +Corepack under the persistent kit volume. + +Check: + +```bash +acq exec -- sh -c 'tail -n 120 ~/.local/state/opendesign/opendesign-install.log' +``` + +Common causes: + +- `registry.npmjs.org:443` blocked. +- GitHub release asset hosts blocked for native prebuild downloads. +- Inspecting proxy CA missing. +- Upstream OpenDesign dependency/build regression at the pinned tag. +- Not enough memory for the Next.js production build. With the earlier 1536 MiB + heap cap, `pnpm --filter @open-design/web build` compiled successfully and then + failed during `Running TypeScript ...` with `JavaScript heap out of memory`. + This kit now defaults `NODE_OPTIONS` to `--max-old-space-size=3072`; if that is + still too small for the active sandbox, increase `NODE_OPTIONS`, use a larger + sandbox, or use a prebuilt OpenDesign artifact if one becomes available. + +The script is fail-soft by design: the sandbox should still start even when the +optional OpenDesign UI is unavailable. + +## Daemon restarts with NODE_MODULE_VERSION mismatch + +If the build completes but the daemon restarts with an error like: + +```text +better_sqlite3.node was compiled against a different Node.js version +NODE_MODULE_VERSION 137 ... This version of Node.js requires NODE_MODULE_VERSION 127 +Node.js v22.x +``` + +then the daemon is being launched with the sandbox's system Node instead of the +kit-managed Node 24 runtime used to build native modules. The startup script +refreshes `PATH` after install so `node` resolves to: + +```text +/home/agent/.local/share/opendesign/tools/node-v24.21.0-linux-/bin/node +``` + +Recreate the sandbox with the current kit. If it still happens, check the daemon +log and effective Node path: + +```bash +acq exec -- sh -c 'tail -n 80 ~/.local/state/opendesign/opendesign-daemon.log' +acq exec -- sh -c ' + case "$(uname -m)" in x86_64|amd64) a=x64 ;; aarch64|arm64) a=arm64 ;; *) a=unknown ;; esac + PATH="$HOME/.local/share/opendesign/tools/node-v24.21.0-linux-$a/bin:$PATH" + node -p '\''process.version + " " + process.versions.modules'\'' +' +``` + +## Host curl returns "Empty reply from server" / connection refused while guest curl works + +The publish relay is probably not running. The daemon binds guest `127.0.0.1` +only on internal port `17456` (on purpose — see the 403 entry below), and +`~/opendesign-relay.mjs` is what listens on published guest port `7456` that +msb/acq publishing actually dials. + +```bash +acq exec -- sh -c 'tail -n 40 ~/.local/state/opendesign/opendesign-relay.log' +acq exec -- sh -c 'pgrep -af "supervisor:opendesign-relay" || echo "relay supervisor not running"' +``` + +Confirm both listeners. `1D20` hex = 7456 and `4430` hex = 17456: + +```bash +acq exec -- sh -c 'grep -Ei ":(1D20|4430)" /proc/net/tcp' +# 0100007F:4430 state 0A -> daemon on 127.0.0.1:17456 (expected) +# :1D20 0A -> relay on published guest port 7456 (expected) +# 00000000:* state 0A -> wrong; see the 403 entry below +``` + +If the relay log shows `no default-route interface yet`, the guest network came +up late; the relay rescans every 10s and should recover on its own. If it shows +`bind failed`, read the reported error code. If `~/opendesign-relay.mjs` is +missing entirely, the sandbox was created with an older kit version — recreate +it. + +## Export diagnostics (or another UI action) returns 403 Forbidden + +OpenDesign gates a subset of its API on the request **peer** address being +loopback (`requireLocalDaemonRequest`, upstream +`apps/daemon/src/http/local-daemon-request.ts`). If the daemon is bound to +`0.0.0.0`, or if the published path bypasses the relay, requests published from +the host arrive with a guest-network peer address and these routes 403 while the +rest of the UI works normally: + +```text +GET /api/diagnostics/export <- Settings -> About -> Export diagnostics +POST /api/strategies/od-next/rollout <- OD Next strategy switch (fails silently) +POST /api/diagnostics/chat-scroll-forensics +``` + +The current kit avoids this by binding the daemon to `127.0.0.1:17456` and +publishing it through the relay on guest port `7456`. If you see a 403 anyway, +check which address holds both ports: + +```bash +acq exec -- sh -c 'grep -Ei ":(1D20|4430)" /proc/net/tcp' +# want 0100007F:4430 (127.0.0.1:17456) for the daemon +# want :1D20 for the relay on published guest port 7456 +# 00000000:* is wrong; a wildcard listener can bypass the relay path +``` + +A daemon on `0.0.0.0` means the sandbox was created with an earlier kit version +or `OPENDESIGN_DAEMON_BIND_HOST` was deliberately set for testing. The startup +script intentionally ignores backend-provided `OD_BIND_HOST` values for the +daemon, because `0.0.0.0` breaks peer-loopback-gated routes. + +Confirm the fix from the host with the exact request the UI button makes: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + -H "Origin: http://localhost:" \ + http://localhost:/api/diagnostics/export +# 200 = working. 403 = the daemon saw a non-loopback peer. +``` + +The daemon also journals its own API failures, which is the fastest way to +confirm the cause — the peer-gated routes appear there and nothing else does: + +```bash +acq exec -- sh -c 'node -e "const fs=require(\"fs\"); const p=process.env.OD_DATA_DIR+\"/diagnostics/environment-evidence.json\"; const j=JSON.parse(fs.readFileSync(p,\"utf8\")); console.log(JSON.stringify({recentApiFailures:j.recentApiFailures||j.apiFailures||null}, null, 2))"' +# The full file may contain environment and diagnostic details; redact before sharing. +``` + +As a fallback that bypasses HTTP entirely, the CLI writes the same bundle: + +```bash +acq exec -- sh -c 'cd ~/.local/share/opendesign/source && node apps/daemon/dist/cli.js diagnostics export /tmp/od-diag.zip --json' +``` + +## Every run fails with "Cannot connect to API" after a long delay + +Symptom: a prompt runs for ~2-3 minutes and the UI shows "This task failed to +run. Please retry." + +Cause: OpenCode never read the `usai-provider` config, so it has no `usai` +provider and fell back to its own hosted gateway (`opencode.ai/zen`), which this +kit's egress policy does not allow. It then exhausts its retry budget. + +OpenDesign launches agent children with `OD_SANDBOX_MODE=1`, which rewrites +`HOME` and `XDG_CONFIG_HOME` to a private agent home under `OD_DATA_DIR`. +OpenCode resolves its global config from those variables, finds the empty stub it +auto-created at `$OD_DATA_DIR/sandbox/config/opencode/opencode.jsonc`, and never +looks at `~/.config/opencode/opencode.jsonc`. + +Confirm from the child's own log — the provider and model names are the tell: + +```bash +acq exec -- sh -c 'grep -E "loading path|providerID|stream error" ~/.local/share/opendesign/data/sandbox/config/data/opencode/log/opencode.log | tail -n 20' +# BAD: providerID=opencode modelID=big-pickle ... url: https://opencode.ai/zen/... +# GOOD: providerID=usai modelID=claude-opus-5 +``` + +And check the failed run's own record: + +```bash +acq exec -- sh -c 'grep -h -E "status|failureDetail" ~/.local/share/opendesign/data/runs/*/state.json 2>/dev/null | tail -n 40' +``` + +The current kit fixes this by exporting `OPENCODE_CONFIG`. Verify it reached the +daemon, and that OpenCode resolves `usai/*` models under the same rewritten +environment OpenDesign uses: + +```bash +acq exec -- sh -c ' + daemon_pid="$(for p in $(pgrep -f "^[^ ]*node .*apps/daemon/dist/cli\\.js"); do + [ -r "/proc/$p/environ" ] || continue + got="$(tr "\0" "\n" < "/proc/$p/environ" | sed -n "s/^OD_PORT=//p")" + [ "$got" = "17456" ] || continue + printf "%s\n" "$p" + break + done)" + [ -n "$daemon_pid" ] && tr "\0" "\n" < "/proc/$daemon_pid/environ" | grep "^OPENCODE_CONFIG="' + +acq exec -- sh -c ' + OPENCODE_CONFIG="$HOME/.config/opencode/opencode.jsonc" \ + HOME="$OD_DATA_DIR/sandbox/agent-home" \ + XDG_CONFIG_HOME="$OD_DATA_DIR/sandbox/config" \ + XDG_DATA_HOME="$OD_DATA_DIR/sandbox/config/data" \ + XDG_CACHE_HOME="$OD_DATA_DIR/sandbox/cache" \ + opencode models | grep "^usai/" | head' +``` + +If `OPENCODE_CONFIG` is absent, the sandbox predates this fix — recreate it. If +it is present but no `usai/*` models resolve, `usai-provider` is not paired or +`USAI_API_KEY` is not injected; see the USAi section below. + +Note there is no workaround through the OpenDesign UI: Settings → Local CLI only +accepts `OPENCODE_BIN` for the opencode agent (upstream `app-config.ts` +allowlist), not a config path or an API key. + +## OpenDesign shows no usable OpenCode agent + +This kit expects an existing OpenCode installation in the sandbox. It does not +install OpenCode. + +Check: + +```bash +acq exec -- sh -c 'command -v opencode || command -v opencode-cli || true' +acq exec -- sh -c 'node -e "const fs=require(\"fs\"); const p=process.env.OD_DATA_DIR+\"/app-config.json\"; const c=JSON.parse(fs.readFileSync(p,\"utf8\")); console.log(JSON.stringify({agentId:c.agentId,onboardingCompleted:c.onboardingCompleted,telemetry:c.telemetry}, null, 2))" 2>/dev/null || true' +``` + +The startup script seeds `agentId: "opencode"` only when no user choice exists. +If OpenCode is missing, create the sandbox with an OpenCode base agent, for +example `acq run opencode /path/to/project`. + +## USAi/OpenCode provider does not work + +This kit does not configure model-provider credentials. Pair it with the default +`usai-provider` kit and inject the USAi key through `acq`: + +```bash +acq secret set-custom -g --host api.gsa.usai.gov --env USAI_API_KEY +``` + +Then check inside the sandbox: + +```bash +acq exec -- sh -c 'test -n "$USAI_API_KEY" && echo USAI_API_KEY-present' +acq exec -- sh -c 'grep -n "api.gsa.usai.gov" ~/.config/opencode/opencode.jsonc 2>/dev/null || true' +``` + +If the config exists and the key is present but OpenDesign runs still fail, see +"Every run fails with Cannot connect to API" above — the config exists but +OpenDesign's sandbox mode may be pointing OpenCode somewhere else. + +Do not copy `USAI_API_KEY` into OpenDesign `media-config.json`; OpenDesign should +launch OpenCode, and OpenCode should read USAi through its own config/env path. + +## OpenDesign media provider keys are missing + +OpenDesign media providers are separate from the core OpenCode/USAi chat path. +For v1, this kit does not seed media provider keys. Configure extra media +providers through OpenDesign settings only if you need them. + +OpenDesign stores those settings under `OD_DATA_DIR`, which this kit maps to: + +```text +/home/agent/.local/share/opendesign/data +``` + +Treat that directory as sensitive local state. + +## State does not persist + +This kit declares a persistent volume at: + +```text +/home/agent/.local/share/opendesign +``` + +If projects/settings disappear after restart, verify the backend applied the +volume declaration: + +```bash +acq exec -- sh -c 'mount | grep /home/agent/.local/share/opendesign || true' +acq exec -- sh -c 'ls -la /home/agent/.local/share/opendesign' +``` + +If the path is not mounted, the active `acq` version/backend may not support the +neutral `volumes` field yet. diff --git a/integrations/isolation/acq-kits/opendesign/docs/decisions/acq-owned-byok.md b/integrations/isolation/acq-kits/opendesign/docs/decisions/acq-owned-byok.md new file mode 100644 index 00000000..90306ed0 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/docs/decisions/acq-owned-byok.md @@ -0,0 +1,61 @@ +# Decision: keep BYOK credentials in ACQ/OpenCode, not OpenDesign kit files + +**Status:** accepted + +## Context + +OpenDesign supports BYOK-style provider configuration, and its media provider +settings can be stored under `OD_DATA_DIR`. In ACQ, however, model-provider +credentials are already handled by the sandbox credential path: operators store a +secret with `acq secret`, ACQ injects it into the sandbox environment, and the +`usai-provider` kit configures OpenCode to reference that environment variable. + +Copying provider keys from the sandbox environment into OpenDesign's persisted +config would create a second secret location and make cleanup/audit harder. + +## Decision + +The OpenDesign kit does not seed provider keys or OpenDesign media config. It +seeds only non-secret OpenDesign app preferences: + +- select the local `opencode` agent when no agent was chosen yet; +- mark onboarding complete; +- do not set a default model. + +For the GSA path, credentials flow through: + +```text +acq secret store -> USAI_API_KEY env -> usai-provider OpenCode config -> OpenCode +``` + +OpenDesign launches OpenCode and inherits that configured environment. Extra +OpenDesign media providers remain manual user configuration. + +Inheriting the environment is necessary but not sufficient. OpenDesign runs agent +children with `OD_SANDBOX_MODE=1`, which rewrites `HOME` and `XDG_CONFIG_HOME` to +a private agent home under `OD_DATA_DIR` (upstream +`apps/daemon/src/sandbox-mode.ts`). OpenCode resolves its global config from +those variables, so it would look in `$OD_DATA_DIR/sandbox/config/opencode/` — +where it auto-creates an empty stub — and never read the `usai-provider` config. +With no `usai` provider defined it falls back to its own hosted gateway +(`opencode.ai/zen`), which is not in this kit's egress allowlist, so every run +exhausts its retries and fails with "Cannot connect to API". + +Startup therefore also exports `OPENCODE_CONFIG`, OpenCode's documented +absolute-path config override, which survives the `HOME`/`XDG_CONFIG_HOME` +rewrite. That names a config **path** only and does not move secret material: +`USAI_API_KEY` stays in the environment and is resolved by that config's own +`{env:USAI_API_KEY}` substitution at run time, preserving this decision. + +## Consequences + +- The kit works with ACQ's existing credential-injection model. +- `USAI_API_KEY` is not copied into `OD_DATA_DIR` by the kit. +- OpenDesign's core agent flow works through OpenCode/USAi when paired with + `usai-provider`. +- The kit depends on `OPENCODE_CONFIG` remaining OpenCode's config-path override + and on `usai-provider` continuing to install a config at + `~/.config/opencode/opencode.jsonc`. Startup logs which config it selected, and + logs a warning when it finds none. +- Users who need OpenDesign media provider keys can still configure them in the + OpenDesign UI, accepting that OpenDesign stores those settings locally. diff --git a/integrations/isolation/acq-kits/opendesign/docs/decisions/disable-api-auth-loopback-boundary.md b/integrations/isolation/acq-kits/opendesign/docs/decisions/disable-api-auth-loopback-boundary.md new file mode 100644 index 00000000..fcfe5139 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/docs/decisions/disable-api-auth-loopback-boundary.md @@ -0,0 +1,111 @@ +# Decision: disable OpenDesign API auth behind the ACQ loopback boundary + +**Status:** accepted + +## Context + +OpenDesign supports API authentication with `OD_API_TOKEN`, but this ACQ kit is +intended for a local, single-user sandbox reached through a host-loopback port. +The sibling browser UI kits use the sandbox plus host-loopback publish as the +security boundary. Adding a generated token would make first use harder and would +not protect against a local process that can already access the host loopback +port and user session. + +The published port must be reachable from the host. msb-style backends dial the +sandbox **guest network IP**, not guest `127.0.0.1`, so something inside the +guest has to listen on that address. + +The obvious way to satisfy that — binding the daemon itself to `0.0.0.0` — is +wrong, and was shipped in the first draft of this kit. OpenDesign gates a subset +of its API on the request **peer address** being loopback +(`requireLocalDaemonRequest`, upstream +`apps/daemon/src/http/local-daemon-request.ts`), which inspects +`req.socket.remoteAddress` and the `Host` header. A daemon bound to `0.0.0.0` +sees a guest-network peer for every request arriving from the host, so those +routes answer `403 Forbidden` while the rest of the UI works normally. Observed +on `open-design-v0.24.0`: + +| Route | Bound `0.0.0.0` | Bound `127.0.0.1` + relay | +|---|---|---| +| `GET /api/health` | 200 | 200 | +| `GET /api/diagnostics/export` | **403** | 200 | +| `POST /api/strategies/od-next/rollout` | **403** | 200 | +| `POST /api/diagnostics/chat-scroll-forensics` | **403** | 200 | + +The user-visible symptom is that Settings → About → **Export diagnostics** +returns 403, and the OD Next strategy switch is silently inert. Both are +especially bad in a kit: diagnostics export is the first thing a user reaches for +when something else breaks. + +The daemon's own failure journal +(`$OD_DATA_DIR/diagnostics/environment-evidence.json`) records exactly this set +of routes failing and nothing else, which is the cleanest confirmation that the +peer-loopback guard — not the export handler — is the cause. + +## Decision + +Run OpenDesign with: + +```bash +OD_DISABLE_API_AUTH=1 +OD_BIND_HOST=127.0.0.1 +OD_PORT=17456 +``` + +The startup script forces the daemon bind through its own +`OPENDESIGN_DAEMON_BIND_HOST` default instead of inheriting a backend-provided +`OD_BIND_HOST`, because ordinary service defaults may be `0.0.0.0`. + +and publish that loopback listener on guest port `7456` with a small, +supervised, kit-managed TCP relay (`files/home/opendesign-relay.mjs`). The relay +is a plain byte forwarder — no parsing and no header rewriting — so SSE run +streams and websockets pass through unchanged, and the daemon always observes a +loopback peer. + +Document that the kit is for trusted, single-user hosts only and that the mapped +port must not be forwarded to a wider interface. + +### Alternatives rejected + +- **Bind `0.0.0.0` and document `od diagnostics export` as the supported path.** + Works for diagnostics (verified: the CLI produced a bundle while the HTTP route + 404/403'd), but leaves `od-next/rollout` broken with no signal to the user, and + leaves a UI button that always fails. +- **Patch upstream so `requireLocalDaemonRequest` honours `OD_ALLOWED_ORIGINS`**, + as the sibling `isLocalSameOrigin` check already does. This is the right + long-term fix and is being raised upstream, but it weakens a deliberate + CSRF/SSRF boundary and should not be carried as a local patch in this kit. +- **Rewrite the `Host` header in the relay.** Unnecessary: OpenDesign's origin + middleware already accepts the loopback `Host` a browser sends to a published + `localhost` port. Not rewriting keeps the relay a byte pipe. + +## Consequences + +- Browser access requires no token retrieval step. +- The kit is consistent with the approved local-UI posture for ACQ browser kits. +- Peer-loopback-gated routes work from the host browser, including diagnostics + export — the path users need when filing a kit bug. +- One more supervised process to reason about. It is fail-soft and logs to + `~/.local/state/opendesign/opendesign-relay.log`; if the relay dies the UI + becomes unreachable from the host while remaining reachable in-guest, which is + a clear and diagnosable failure. `TROUBLESHOOTING.md` names it first for any + 403 or connection-refused symptom. +- Exposure is narrower than the `0.0.0.0` draft: the guest network interface is + still the surface ACQ publishing reaches, but the relay binds only the + default-route guest interface and accepts only loopback, the default gateway + peer, and optional `OPENDESIGN_RELAY_ALLOWED_PEERS` entries for + backend-specific forwarders. +- The relay peer filter is defense-in-depth against accidental guest-network + reachability. It is not the primary trust boundary and does not constrain a + hostile process already running inside the sandbox: the agent can run code with + the sandbox user's authority, and in local ACQ environments may have enough + privilege to stop or replace the relay. On backends where the guest network is + shared across sandboxes, other guests are exactly the accidental-reachability + case this filter is meant to reduce, not a boundary against a compromised guest. +- Anyone with access to the host loopback port, or to an explicitly allowed relay + peer on the guest network, can drive OpenDesign and the agents it launches, so + the kit must not be used on untrusted multi-user hosts or with forwarded public + ports. +- If upstream later accepts an operator-declared-origin exemption for + `requireLocalDaemonRequest`, the relay can be deleted and `OD_BIND_HOST` + returned to `0.0.0.0`. diff --git a/integrations/isolation/acq-kits/opendesign/docs/decisions/source-install-headless-daemon.md b/integrations/isolation/acq-kits/opendesign/docs/decisions/source-install-headless-daemon.md new file mode 100644 index 00000000..ecf62aa5 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/docs/decisions/source-install-headless-daemon.md @@ -0,0 +1,60 @@ +# Decision: source-install OpenDesign and run the headless daemon + +**Status:** accepted + +## Context + +OpenDesign can be run from a published container image or from a source checkout. +The container image is convenient for standalone deployments, but an ACQ kit has a +different goal: run OpenDesign in the same sandbox process environment as the +already-installed coding agents. + +OpenDesign launches local agent CLIs such as OpenCode. If OpenDesign runs inside +a nested container, the existing sandbox `opencode` binary, OpenCode config, and +ACQ-injected environment are not naturally visible without extra mounts and PATH +plumbing. That would make the kit harder to reason about and would duplicate +responsibility already owned by ACQ and sibling kits. + +OpenDesign also requires Node 24 and pnpm 10.33.2. The OpenCode sandbox base may +carry a different Node major, so assuming the base `node` is fragile. + +## Decision + +Install OpenDesign from the pinned upstream source tag +`open-design-v0.24.0`, verify the checked-out commit, install a kit-managed Node +24 runtime under the kit volume, activate pnpm 10.33.2 through Corepack, and run +the built daemon headlessly on guest loopback: + +```bash +node apps/daemon/dist/cli.js --host 127.0.0.1 --port 17456 --no-open +``` + +A supervised relay publishes that loopback listener on guest port `7456`, the +port that ACQ/MSB create-time port publishing dials; see +[`disable-api-auth-loopback-boundary.md`](disable-api-auth-loopback-boundary.md). + +The install runs during the startup phase, not create time. It is idempotent and +fail-soft: failures are logged but do not fail sandbox creation/startup. + +## Consequences + +- OpenDesign sees the sandbox's existing `opencode` and OpenCode configuration. +- The kit does not need nested Docker/Podman. +- First boot can be slow because it downloads Node, installs dependencies, and + builds OpenDesign. +- Unlike sibling UI kits that install one published artifact, this source build + executes OpenDesign's pinned `pnpm install --frozen-lockfile`, package lifecycle + scripts, bootstrap build, and Next.js production build inside the sandbox. The + commit, Node archive, pnpm version, and lockfile are pinned, but reviewers + should treat this as a larger build-time supply-chain surface. +- First boot needs enough memory for the Next.js production build. Direct + exercise with a 1536 MiB Node heap reached the web build, compiled + successfully, and failed during `Running TypeScript ...` with `JavaScript heap + out of memory`; the kit now defaults to a 3072 MiB Node heap. Host verification + should use a larger sandbox or adopt a prebuilt artifact if upstream publishes + one if that is still insufficient. +- The persistent kit volume caches the source checkout, Node runtime, Corepack + state, pnpm executable home, explicit pnpm store, and build marker for later + starts. +- The kit must allow egress to GitHub, nodejs.org, and registry.npmjs.org for + first boot. diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh new file mode 100755 index 00000000..0418642f --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh @@ -0,0 +1,144 @@ +#!/bin/sh +# opendesign-install.sh — install/update a pinned OpenDesign source checkout and +# kit-managed Node 24 runtime under the kit's persistent volume. +# +# This script is called from opendesign-start.sh on every sandbox start. It is +# idempotent and fail-soft: any install/build failure is logged and returns 0 so +# an optional browser UI cannot break sandbox creation or startup. + +set -eu + +APP_HOME="${OPENDESIGN_HOME:-$HOME/.local/share/opendesign}" +STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}/opendesign" +INSTALL_LOG="$STATE_HOME/opendesign-install.log" +SOURCE_DIR="$APP_HOME/source" +TOOLS_DIR="$APP_HOME/tools" +BUILD_MARKER="$APP_HOME/.built" + +OPENDESIGN_REPO="${OPENDESIGN_REPO:-https://github.com/nexu-io/open-design.git}" +OPENDESIGN_REF="${OPENDESIGN_REF:-open-design-v0.24.0}" +OPENDESIGN_COMMIT="${OPENDESIGN_COMMIT:-0d3a14c1df6dc5017f3cc3ef05b24558250c220b}" +OPENDESIGN_NODE_VERSION="${OPENDESIGN_NODE_VERSION:-24.21.0}" +OPENDESIGN_PNPM_VERSION="${OPENDESIGN_PNPM_VERSION:-10.33.2}" + +mkdir -p "$APP_HOME" "$STATE_HOME" "$TOOLS_DIR" + +log() { + printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >>"$INSTALL_LOG" +} + +fail_soft() { + log "ERROR: $*" + printf '%s\n' "opendesign install failed; see $INSTALL_LOG" >&2 + exit 0 +} + +run_logged() { + log "+ $*" + "$@" >>"$INSTALL_LOG" 2>&1 || fail_soft "command failed: $*" +} + +arch="$(uname -m)" +case "$arch" in + x86_64|amd64) + node_arch="x64" + node_sha="6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" + ;; + aarch64|arm64) + node_arch="arm64" + node_sha="724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5" + ;; + *) + fail_soft "unsupported architecture for kit-managed Node: $arch" + ;; +esac + +node_dir="$TOOLS_DIR/node-v${OPENDESIGN_NODE_VERSION}-linux-${node_arch}" +node_tar="node-v${OPENDESIGN_NODE_VERSION}-linux-${node_arch}.tar.gz" +node_url="https://nodejs.org/dist/v${OPENDESIGN_NODE_VERSION}/${node_tar}" + +if [ ! -x "$node_dir/bin/node" ]; then + tmp="$TOOLS_DIR/${node_tar}.tmp" + log "installing Node ${OPENDESIGN_NODE_VERSION} (${node_arch})" + if command -v curl >/dev/null 2>&1; then + run_logged curl -fsSL "$node_url" -o "$tmp" + elif command -v wget >/dev/null 2>&1; then + run_logged wget -O "$tmp" "$node_url" + else + fail_soft "neither curl nor wget is available to download Node" + fi + actual_sha="$(sha256sum "$tmp" | awk '{print $1}')" + if [ "$actual_sha" != "$node_sha" ]; then + rm -f "$tmp" + fail_soft "Node archive SHA-256 mismatch for $node_tar" + fi + rm -rf "$node_dir" + run_logged tar -xzf "$tmp" -C "$TOOLS_DIR" + rm -f "$tmp" +fi + +export PATH="$node_dir/bin:$PATH" +export COREPACK_HOME="$APP_HOME/corepack" +export PNPM_HOME="$APP_HOME/pnpm-home" +export npm_config_store_dir="$APP_HOME/pnpm-store" +mkdir -p "$COREPACK_HOME" "$PNPM_HOME" "$npm_config_store_dir" +export PATH="$PNPM_HOME:$PATH" + +node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" +[ "$node_major" = "24" ] || fail_soft "Node 24 unavailable after kit-managed install" + +# Route Node/npm/pnpm through the sandbox proxy and CA bundle when present. The +# zscaler-ca-certificate kit populates the system trust store; PROXY_CA_CERT_B64 +# carries the sandbox proxy CA when the backend provides it. +[ -n "${HTTPS_PROXY:-${https_proxy:-}}" ] && export npm_config_https_proxy="${HTTPS_PROXY:-$https_proxy}" +[ -n "${HTTP_PROXY:-${http_proxy:-}}" ] && export npm_config_proxy="${HTTP_PROXY:-$http_proxy}" +ca_bundle="$STATE_HOME/ca-bundle.pem" +: >"$ca_bundle" +if [ -n "${PROXY_CA_CERT_B64:-}" ]; then + proxy_ca_tmp="$STATE_HOME/proxy-ca.pem.tmp" + if printf %s "$PROXY_CA_CERT_B64" | base64 -d >"$proxy_ca_tmp" 2>/dev/null; then + cat "$proxy_ca_tmp" >>"$ca_bundle" + else + log "WARNING: ignoring invalid PROXY_CA_CERT_B64; could not decode proxy CA" + fi + rm -f "$proxy_ca_tmp" +fi +[ -f /etc/ssl/certs/ca-certificates.crt ] && cat /etc/ssl/certs/ca-certificates.crt >>"$ca_bundle" +[ -s "$ca_bundle" ] && export NODE_EXTRA_CA_CERTS="$ca_bundle" + +if ! command -v git >/dev/null 2>&1; then + fail_soft "git is required to fetch OpenDesign source" +fi + +if [ ! -d "$SOURCE_DIR/.git" ]; then + rm -rf "$SOURCE_DIR" + log "cloning OpenDesign $OPENDESIGN_REF" + run_logged git clone --filter=blob:none --no-tags "$OPENDESIGN_REPO" "$SOURCE_DIR" +fi + +cd "$SOURCE_DIR" +run_logged git fetch --depth=1 origin "$OPENDESIGN_REF" +run_logged git checkout --detach FETCH_HEAD +actual_commit="$(git rev-parse HEAD 2>/dev/null || true)" +[ "$actual_commit" = "$OPENDESIGN_COMMIT" ] || fail_soft "OpenDesign commit mismatch: got $actual_commit expected $OPENDESIGN_COMMIT" + +marker_value="${OPENDESIGN_COMMIT}:${OPENDESIGN_NODE_VERSION}:${OPENDESIGN_PNPM_VERSION}" +if [ -f "$BUILD_MARKER" ] && [ "$(cat "$BUILD_MARKER" 2>/dev/null || true)" = "$marker_value" ] && [ -f "$SOURCE_DIR/apps/daemon/dist/cli.js" ] && [ -d "$SOURCE_DIR/apps/web/out" ]; then + log "OpenDesign already built for $marker_value" + exit 0 +fi + +run_logged corepack enable --install-directory "$PNPM_HOME" +run_logged corepack prepare "pnpm@${OPENDESIGN_PNPM_VERSION}" --activate +run_logged pnpm --version +export CI="${CI:-true}" +export NEXT_TELEMETRY_DISABLED="1" +export NODE_ENV="${NODE_ENV:-production}" +export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=3072}" +run_logged pnpm install --frozen-lockfile +# OpenDesign's workspace packages must be built before the daemon can typecheck. +# The upstream bootstrap script does a dependency-aware build of those packages. +run_logged pnpm bootstrap +run_logged pnpm --filter @open-design/web build +printf '%s\n' "$marker_value" >"$BUILD_MARKER" +log "OpenDesign build complete: $marker_value" diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs new file mode 100644 index 00000000..41e9882b --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs @@ -0,0 +1,286 @@ +// opendesign-relay.mjs — loopback-preserving TCP relay for the OpenDesign kit. +// +// WHY THIS EXISTS +// +// OpenDesign gates a number of its own API routes on the request PEER address +// being loopback (upstream apps/daemon/src/http/local-daemon-request.ts, +// `requireLocalDaemonRequest`). That guard inspects `req.socket.remoteAddress` +// and the Host header, and it is applied to routes the browser UI calls +// directly — including `GET /api/diagnostics/export` (Settings -> About -> +// Export diagnostics) and `POST /api/strategies/od-next/rollout`. +// +// acq/msb create-time port publishing dials the sandbox GUEST NETWORK address, +// not guest loopback. So a daemon bound to 0.0.0.0 sees a guest-network peer +// for every browser request arriving from the host, and those routes answer +// 403 Forbidden even though the rest of the UI works. Binding the daemon to +// loopback alone is not an option either: then the published port has nothing +// to connect to. +// +// This relay resolves the conflict. The daemon binds 127.0.0.1 only. The relay +// binds the default-route guest network interface and forwards each connection +// to 127.0.0.1 from localAddress 127.0.0.1, so the daemon always observes a +// loopback peer. It is a plain byte relay: no parsing, no header rewriting, so +// websockets, SSE, and streaming responses pass through untouched. +// +// SECURITY POSTURE (unchanged by this file) +// +// The relay does not intentionally widen exposure. The guest network interface +// was already the reachable surface when the daemon bound 0.0.0.0; the host side +// of the mapping remains loopback-only. The relay additionally accepts only +// loopback and the default gateway peer by default, with an explicit +// OPENDESIGN_RELAY_ALLOWED_PEERS override for backend-specific forwarders. This +// is defense-in-depth against accidental guest-network reachability, not a +// boundary against code already running inside the sandbox. See +// docs/decisions/disable-api-auth-loopback-boundary.md. The relay deliberately +// does NOT rewrite the Host header: OpenDesign's own origin middleware already +// accepts the loopback Host a browser sends to a published localhost port. +// +// Fail-soft, like the rest of the kit: a bind failure is logged and the process +// keeps serving whatever else bound, rather than taking the UI down. + +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; + +const SELF_TEST = process.argv[2] === '--self-test'; +const LISTEN_PORT = Number.parseInt(SELF_TEST ? '1' : (process.argv[2] ?? ''), 10); +const TARGET_PORT = Number.parseInt(SELF_TEST ? '1' : (process.argv[3] ?? process.argv[2] ?? ''), 10); +const TARGET_HOST = '127.0.0.1'; +// How often to look for interfaces that appeared after startup. The relay may +// win the race against sandbox network setup on a cold boot. +const RESCAN_INTERVAL_MS = Number.parseInt( + process.env.OPENDESIGN_RELAY_RESCAN_MS ?? '10000', + 10, +); + +if (!Number.isInteger(LISTEN_PORT) || LISTEN_PORT < 1 || LISTEN_PORT > 65535 + || !Number.isInteger(TARGET_PORT) || TARGET_PORT < 1 || TARGET_PORT > 65535) { + console.error(`[relay] usage: opendesign-relay.mjs [target-port] (got ${JSON.stringify(process.argv.slice(2))})`); + process.exit(2); +} + +const log = (...parts) => { + console.log(`${new Date().toISOString()} [relay]`, ...parts); +}; + +function parseDefaultIpv4Route(routeTable) { + let selected = { iface: '', gateway: '', metric: Number.POSITIVE_INFINITY }; + for (const line of routeTable.trim().split('\n').slice(1)) { + const fields = line.trim().split(/\s+/); + const flags = Number.parseInt(fields[3], 16); + const metric = Number.parseInt(fields[6], 10); + if (fields[1] !== '00000000' || !fields[2] || !Number.isFinite(flags) || !(flags & 0x1) + || !Number.isFinite(metric) || metric < 0 || !/^[0-9a-fA-F]{8}$/.test(fields[2])) continue; + const hex = fields[2].match(/../g); + const gateway = hex.reverse().map((part) => Number.parseInt(part, 16)).join('.'); + const hasGateway = gateway !== '0.0.0.0'; + const selectedHasGateway = selected.gateway !== ''; + // A point-to-point route has no forwarder peer to authorize. Prefer any + // gateway-bearing default route over it, then use metric within that class. + if ((!hasGateway && selectedHasGateway) + || (hasGateway === selectedHasGateway && metric >= selected.metric)) continue; + selected = { + iface: fields[0], + // Point-to-point default routes encode no peer gateway as 0.0.0.0. It + // cannot be a TCP client address, so omit it from the allowlist. + gateway: hasGateway ? gateway : '', + metric, + }; + } + return { iface: selected.iface, gateway: selected.gateway }; +} + +function defaultIpv4Route() { + try { + return parseDefaultIpv4Route(fs.readFileSync('/proc/net/route', 'utf8')); + } catch { + // Non-Linux or unreadable route table: fail closed until the route is visible. + } + return { iface: '', gateway: '' }; +} + +/** + * Publishable addresses: non-internal IPv4/IPv6 addresses on the default-route + * guest interface only. `internal` is Node's own flag for loopback, which the + * daemon already owns; binding it here would collide with the daemon. + */ +function publishableAddresses(route = defaultIpv4Route(), interfaces = os.networkInterfaces()) { + if (!route.iface) return []; + const found = []; + for (const [name, entries] of Object.entries(interfaces)) { + if (name !== route.iface) continue; + for (const entry of entries ?? []) { + if (!entry || entry.internal) continue; + if (entry.family !== 'IPv4' && entry.family !== 'IPv6') continue; + // A link-local IPv6 address needs its scope id to bind, and carries no + // routable meaning for a published port. Skip it rather than log a + // recurring EINVAL. + if (entry.family === 'IPv6' && entry.address.toLowerCase().startsWith('fe80:')) continue; + found.push(entry.address); + } + } + return [...new Set(found)]; +} + +const bound = new Set(); +// Addresses whose bind failed for a reason a rescan will never fix. Retrying +// them every interval would turn one misconfiguration into an endless log. +const permanentlyFailed = new Set(); +const warnedAcceptedPeers = new Set(); +const warnedDeniedPeers = new Set(); + +function normalizePeerAddress(address) { + if (typeof address !== 'string') return ''; + const normalized = address.trim().toLowerCase().replace(/^\[|\]$/g, ''); + return normalized.startsWith('::ffff:') ? normalized.slice('::ffff:'.length) : normalized; +} + +function allowedPeerAddresses(route = defaultIpv4Route(), configuredValue = process.env.OPENDESIGN_RELAY_ALLOWED_PEERS ?? '') { + const configured = configuredValue + .split(',') + .map((value) => normalizePeerAddress(value)) + .filter(Boolean); + return new Set([ + '127.0.0.1', + '::1', + '0:0:0:0:0:0:0:1', + route.gateway, + ...configured, + ].filter(Boolean)); +} + +function isAllowedPeer(address, peers = allowedPeerAddresses()) { + return peers.has(normalizePeerAddress(address)); +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`); + } +} + +function assert(condition, label) { + if (!condition) throw new Error(label); +} + +if (SELF_TEST) { + assertEqual(normalizePeerAddress('::ffff:127.0.0.1'), '127.0.0.1', 'IPv4-mapped loopback'); + assertEqual(normalizePeerAddress('[::1]'), '::1', 'bracketed IPv6 loopback'); + const route = parseDefaultIpv4Route('Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\neth0\t00000000\t0141A8C0\t0003\t0\t0\t100\n'); + assertEqual(route.iface, 'eth0', 'default route interface'); + assertEqual(route.gateway, '192.168.65.1', 'default route gateway'); + const pointToPoint = parseDefaultIpv4Route('Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\nppp0\t00000000\t00000000\t0003\t0\t0\t100\n'); + assertEqual(pointToPoint.gateway, '', 'point-to-point route has no gateway peer'); + const noRoute = parseDefaultIpv4Route('Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\n'); + assertEqual(noRoute.iface, '', 'missing default route has no interface'); + assertEqual(noRoute.gateway, '', 'missing default route has no gateway'); + const multipleRoutes = parseDefaultIpv4Route('Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\neth0\t00000000\t0141A8C0\t0003\t0\t0\t100\neth1\t00000000\t0164A8C0\t0003\t0\t0\t10\neth2\t00000000\t012CA8C0\t0000\t0\t0\t1\n'); + assertEqual(multipleRoutes.iface, 'eth1', 'lowest-metric active default route is selected'); + assertEqual(multipleRoutes.gateway, '192.168.100.1', 'selected route gateway is decoded'); + const pointToPointBeforeGateway = parseDefaultIpv4Route('Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\nppp0\t00000000\t00000000\t0003\t0\t0\t5\neth0\t00000000\t0141A8C0\t0003\t0\t0\t100\n'); + assertEqual(pointToPointBeforeGateway.iface, 'eth0', 'gateway route is preferred over a lower-metric point-to-point route'); + assertEqual(pointToPointBeforeGateway.gateway, '192.168.65.1', 'preferred gateway route supplies the forwarder peer'); + const malformedGateway = parseDefaultIpv4Route('Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\neth0\t00000000\t41A8C0\t0003\t0\t0\t100\n'); + assertEqual(malformedGateway.iface, '', 'malformed gateway route is rejected'); + const peers = allowedPeerAddresses(route, ' 192.0.2.44, [::1], ::ffff:192.0.2.45 '); + assert(isAllowedPeer('192.168.65.1', peers), 'default gateway peer is allowed'); + assert(isAllowedPeer('::ffff:192.0.2.45', peers), 'configured IPv4-mapped peer is normalized and allowed'); + assert(!isAllowedPeer('192.168.65.42', peers), 'non-forwarder peer is denied'); + assert(!allowedPeerAddresses(pointToPoint).has('0.0.0.0'), 'unspecified gateway is not allowed'); + assertEqual(publishableAddresses(noRoute, { eth0: [{ address: '192.168.65.2', family: 'IPv4', internal: false }] }).length, 0, 'no route binds no addresses'); + const addresses = publishableAddresses(route, { + eth0: [ + { address: '192.168.65.2', family: 'IPv4', internal: false }, + { address: 'fe80::1', family: 'IPv6', internal: false }, + ], + eth1: [{ address: '192.168.66.2', family: 'IPv4', internal: false }], + }); + assertEqual(addresses.join(','), '192.168.65.2', 'only the default-route interface is published'); + console.log('relay self-test passed'); + process.exit(0); +} + +function relayConnection(client) { + const peer = normalizePeerAddress(client.remoteAddress); + if (!isAllowedPeer(peer)) { + if (!warnedDeniedPeers.has(peer)) { + warnedDeniedPeers.add(peer); + log(`denying non-forwarder peer ${peer || '(unknown)'}`); + } + client.destroy(); + return; + } + if (!warnedAcceptedPeers.has(peer)) { + warnedAcceptedPeers.add(peer); + log(`accepting peer ${peer || '(unknown)'}`); + } + + const upstream = net.connect({ + port: TARGET_PORT, + host: TARGET_HOST, + localAddress: TARGET_HOST, + }); + // A relay must not inherit the default 'error' -> throw behaviour on either + // side: a client that hangs up mid-response, or a daemon restart, is normal + // operation here and must not take the relay down. + client.on('error', () => upstream.destroy()); + upstream.on('error', () => client.destroy()); + client.on('close', () => upstream.destroy()); + upstream.on('close', () => client.destroy()); + // No timeouts: SSE run streams and websockets are long-lived by design. + client.pipe(upstream); + upstream.pipe(client); +} + +function bind(address) { + if (bound.has(address) || permanentlyFailed.has(address)) return; + const server = net.createServer(relayConnection); + server.on('error', (err) => { + bound.delete(address); + const code = err?.code; + if (code === 'EADDRINUSE') { + // Something already serves this address:port — most likely a previous + // relay generation, or a daemon still bound to 0.0.0.0 from an older kit + // version. Either way the port is reachable, which is the goal. + log(`address already in use, leaving it alone: ${address}:${LISTEN_PORT}`); + permanentlyFailed.add(address); + return; + } + if (code === 'EADDRNOTAVAIL' || code === 'EINVAL') { + // The interface went away between scan and bind. A later rescan can + // legitimately retry this one. + log(`address unavailable (will retry): ${address}:${LISTEN_PORT} (${code})`); + return; + } + log(`bind failed: ${address}:${LISTEN_PORT} (${code ?? err?.message})`); + permanentlyFailed.add(address); + }); + server.listen(LISTEN_PORT, address, () => { + bound.add(address); + log(`forwarding ${address}:${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}`); + }); +} + +function scan() { + const addresses = publishableAddresses(); + if (addresses.length === 0 && bound.size === 0) { + log('no default-route interface address yet; will rescan'); + } + for (const address of addresses) bind(address); +} + +log(`starting for published port ${LISTEN_PORT}; daemon expected on ${TARGET_HOST}:${TARGET_PORT}`); +scan(); +const rescan = setInterval(scan, RESCAN_INTERVAL_MS); +rescan.unref?.(); +// Keep the process alive even during a window where nothing is bound (cold +// boot, or every interface temporarily gone), so the rescan can recover +// instead of the supervisor thrashing. +setInterval(() => {}, 1 << 30); + +for (const signal of ['SIGTERM', 'SIGINT']) { + process.on(signal, () => { + log(`received ${signal}; exiting`); + process.exit(0); + }); +} diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh new file mode 100755 index 00000000..bd7a2904 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh @@ -0,0 +1,202 @@ +#!/bin/sh +# opendesign-start.sh — install/update OpenDesign, seed default local-agent +# preferences, and supervise the headless daemon/UI. +# +# Runs as the agent user in the background on every sandbox start. It is +# idempotent and fail-soft: install/build errors are logged, but this script never +# fails the sandbox over an optional browser UI. + +set -eu + +APP_HOME="${OPENDESIGN_HOME:-$HOME/.local/share/opendesign}" +STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}/opendesign" +SOURCE_DIR="$APP_HOME/source" +NODE_VERSION="${OPENDESIGN_NODE_VERSION:-24.21.0}" +PNPM_VERSION="${OPENDESIGN_PNPM_VERSION:-10.33.2}" +OPENDESIGN_PUBLISHED_PORT="${OPENDESIGN_PUBLISHED_PORT:-7456}" +# Run the daemon on a different loopback-only port from the published relay. If +# both use the same port, ACQ can still reach the daemon directly and bypass the +# relay, which makes peer-loopback-gated routes return 403. +OD_PORT="${OPENDESIGN_DAEMON_PORT:-17456}" +# Loopback-only on purpose. OpenDesign gates several routes on the request PEER +# being a loopback address, so the daemon must never see a guest-network peer. +# Reachability for acq/msb port publishing is provided by opendesign-relay.mjs, +# which binds the guest network address and forwards to this loopback listener. +# See docs/decisions/disable-api-auth-loopback-boundary.md. +# Do not inherit OD_BIND_HOST from the backend: acq/msb may set it to 0.0.0.0 +# for ordinary services, but that makes OpenDesign's peer-loopback-gated routes +# return 403. Use the kit-specific override only when deliberately testing. +OD_BIND_HOST="${OPENDESIGN_DAEMON_BIND_HOST:-127.0.0.1}" +OD_DATA_DIR="${OD_DATA_DIR:-$APP_HOME/data}" +DAEMON_LOG="$STATE_HOME/opendesign-daemon.log" +RELAY_LOG="$STATE_HOME/opendesign-relay.log" +SEED_LOG="$STATE_HOME/opendesign-seed.log" +INSTALL_SCRIPT="$HOME/opendesign-install.sh" +RELAY_SCRIPT="$HOME/opendesign-relay.mjs" +RESTART_DELAY="${OPENDESIGN_RESTART_DELAY:-5}" + +mkdir -p "$APP_HOME" "$STATE_HOME" "$OD_DATA_DIR" + +log() { + printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >>"$DAEMON_LOG" +} + +arch="$(uname -m)" +case "$arch" in + x86_64|amd64) node_arch="x64" ;; + aarch64|arm64) node_arch="arm64" ;; + *) node_arch="" ;; +esac + +if [ -n "$node_arch" ]; then + NODE_DIR="$APP_HOME/tools/node-v${NODE_VERSION}-linux-${node_arch}" + if [ -x "$NODE_DIR/bin/node" ]; then + PATH="$NODE_DIR/bin:$PATH" + fi +fi +export PATH="$APP_HOME/pnpm-home:$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" +export COREPACK_HOME="$APP_HOME/corepack" +export PNPM_HOME="$APP_HOME/pnpm-home" +export OD_BIND_HOST OD_PORT OD_DATA_DIR OPENDESIGN_PUBLISHED_PORT +export OD_WEB_PORT="${OD_WEB_PORT:-$OPENDESIGN_PUBLISHED_PORT}" +export OD_DISABLE_API_AUTH="${OD_DISABLE_API_AUTH:-1}" +export OD_SANDBOX_MODE="${OD_SANDBOX_MODE:-1}" + +# Make OpenDesign-spawned agent processes see the same configured OpenCode binary +# and model-provider environment the sandbox already has. No model key is copied +# into OpenDesign state; usai-provider + acq injection remain the credential path. +if command -v opencode >/dev/null 2>&1; then + export OPENCODE_BIN="$(command -v opencode)" +fi + +# Name the OpenCode global config explicitly. +# +# OpenDesign runs agent children with OD_SANDBOX_MODE=1, which rewrites HOME and +# XDG_CONFIG_HOME to a private agent home under OD_DATA_DIR (upstream +# apps/daemon/src/sandbox-mode.ts). OpenCode then resolves its global config to +# /sandbox/config/opencode/, where it auto-creates an empty stub — +# so the usai-provider kit's config is never read, no `usai` provider is defined, +# and OpenCode silently falls back to its own hosted gateway (opencode.ai/zen), +# which is not in this kit's egress allowlist. Every run then burns its retry +# budget and fails with "Cannot connect to API". +# +# OPENCODE_CONFIG is OpenCode's documented absolute-path override and survives +# the HOME/XDG rewrite, so the same config the sandbox uses interactively is the +# one OpenDesign-launched runs use. This names a config PATH only; no key +# material is copied. USAI_API_KEY stays in the environment and is resolved by +# that config's own {env:USAI_API_KEY} substitution at run time. +if [ -z "${OPENCODE_CONFIG:-}" ]; then + for candidate in \ + "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.jsonc" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json" + do + if [ -f "$candidate" ]; then + export OPENCODE_CONFIG="$candidate" + log "using OpenCode config $candidate for OpenDesign-launched runs" + break + fi + done + if [ -z "${OPENCODE_CONFIG:-}" ]; then + log "no OpenCode global config found; OpenDesign runs will use OpenCode defaults" + fi +fi + +if [ -x "$INSTALL_SCRIPT" ]; then + sh "$INSTALL_SCRIPT" || true +else + log "install script missing at $INSTALL_SCRIPT" +fi + +# On first start the kit-managed Node directory is created by the installer above, +# so refresh PATH before seeding config or launching the daemon. Otherwise the +# supervisor can fall back to the sandbox's system Node and fail native modules. +if [ -n "$node_arch" ]; then + NODE_DIR="$APP_HOME/tools/node-v${NODE_VERSION}-linux-${node_arch}" + if [ -x "$NODE_DIR/bin/node" ]; then + PATH="$NODE_DIR/bin:$PATH" + export PATH + fi +fi + +if [ ! -f "$SOURCE_DIR/apps/daemon/dist/cli.js" ]; then + log "OpenDesign daemon entrypoint missing; install likely failed" + exit 0 +fi + +# Seed OpenDesign app preferences before first use. Keep this deliberately small: +# select OpenCode and mark onboarding done, but DO NOT seed a model or any keys. +seed_app_config() { + mkdir -p "$OD_DATA_DIR" + node - "$OD_DATA_DIR/app-config.json" >>"$SEED_LOG" 2>&1 <<'NODE' +const fs = require('node:fs'); +const path = process.argv[2]; +let config = {}; +try { + config = JSON.parse(fs.readFileSync(path, 'utf8')); + if (!config || typeof config !== 'object' || Array.isArray(config)) config = {}; +} catch (err) { + if (!err || err.code !== 'ENOENT') config = {}; +} +let changed = false; +if (!config.agentId) { + config.agentId = 'opencode'; + changed = true; +} +if (config.onboardingCompleted !== true) { + config.onboardingCompleted = true; + changed = true; +} +if (!config.telemetry || typeof config.telemetry !== 'object' || Array.isArray(config.telemetry)) { + config.telemetry = { metrics: false, content: false, artifactManifest: false }; + changed = true; +} +if (changed) { + fs.mkdirSync(require('node:path').dirname(path), { recursive: true }); + fs.writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); +} +NODE +} +seed_app_config || true + +supervisor_running() { pgrep -u "$(id -u)" -f "supervisor:$1" >/dev/null 2>&1; } + +# Publish the loopback daemon on the guest network address so acq/msb port +# publishing can reach it, WITHOUT letting the daemon see a non-loopback peer. +# Supervised separately from the daemon: the relay is cheap to restart and must +# survive a daemon restart, and a missing relay must not stop the UI from +# working in-guest. See opendesign-relay.mjs for the full rationale. +if [ "$OD_BIND_HOST" = "127.0.0.1" ] || [ "$OD_BIND_HOST" = "localhost" ]; then + if [ ! -f "$RELAY_SCRIPT" ]; then + log "relay script missing at $RELAY_SCRIPT; published port will not be reachable from the host" + elif supervisor_running opendesign-relay; then + log "OpenDesign relay supervisor already running" + else + ( + sh -c ' + while :; do + echo "[supervisor] starting OpenDesign relay at $(date -u +%FT%TZ)" + node "$4" "$1" "$2" || true + echo "[supervisor] OpenDesign relay exited; restarting in ${3}s" + sleep "$3" + done + ' "supervisor:opendesign-relay" "$OPENDESIGN_PUBLISHED_PORT" "$OD_PORT" "$RESTART_DELAY" "$RELAY_SCRIPT" + ) >>"$RELAY_LOG" 2>&1 & + fi +fi + +if supervisor_running opendesign-daemon; then + log "OpenDesign daemon supervisor already running" + exit 0 +fi + +( + cd "$SOURCE_DIR" + sh -c ' + while :; do + echo "[supervisor] starting OpenDesign at $(date -u +%FT%TZ)" + node apps/daemon/dist/cli.js --host "$1" --port "$2" --no-open || true + echo "[supervisor] OpenDesign exited; restarting in ${3}s" + sleep "$3" + done + ' "supervisor:opendesign-daemon" "$OD_BIND_HOST" "$OD_PORT" "$RESTART_DELAY" +) >>"$DAEMON_LOG" 2>&1 & diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify new file mode 100755 index 00000000..af53c338 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -0,0 +1,464 @@ +#!/usr/bin/env bash +# verify — host-side validation for the opendesign acq mixin kit. +# +# Default: offline checks only (schema, source paths, script syntax, and source +# invariants). Live sandbox verification is opt-in with RUN_ACQ=1 and must be run +# from a host that can create ACQ sandboxes. + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REPO_ROOT="$(cd "$KIT_DIR/../../../.." && pwd)" +SBX_NAME="opendesign-verify-$$" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/opendesign-verify.XXXXXX")" +CREATE_LOG="$WORK/create.log" +KEEP="${KEEP:-0}" +RUN_ACQ="${RUN_ACQ:-0}" +READY_TIMEOUT="${READY_TIMEOUT:-600}" +POLL_INTERVAL="${POLL_INTERVAL:-5}" +SENTINEL_SECRET="OPENDESIGN_VERIFY_SENTINEL_${SBX_NAME}" + +pass=0 +fail=0 +skip_count=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"; skip_count=$((skip_count+1)); } +info() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +cleanup() { + if [ "$KEEP" = "1" ]; then + printf '\nKEEP=1 set; leaving sandbox %s and %s in place.\n' "$SBX_NAME" "$WORK" >&2 + return + fi + acq rm "$SBX_NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" >/dev/null 2>&1 || true +} + +interrupt() { + trap - INT TERM + cleanup + exit 130 +} +trap cleanup EXIT +trap interrupt INT TERM + +in_acq() { + acq exec "$SBX_NAME" -- sh -c "$1" /dev/null || true +} + +info "0. Preconditions" +[ -f "$KIT_DIR/spec.yaml" ] && ok "kit spec.yaml present" || { bad "spec.yaml missing"; exit 1; } + +info "1. Offline gates" +VALIDATOR="$REPO_ROOT/integrations/isolation/acq-kits/validate-kits.py" +validator_required=1 +[ "${ALLOW_PARTIAL_VERIFY:-0}" = "1" ] && validator_required=0 +if ! command -v python3 >/dev/null 2>&1; then + if [ "$validator_required" -eq 1 ]; then + bad "validate-kits.py not run (python3 not on PATH)" + else + skip "validate-kits.py not run (python3 not on PATH); ALLOW_PARTIAL_VERIFY=1 permits this skip" + fi +elif [ ! -f "$VALIDATOR" ]; then + if [ "$validator_required" -eq 1 ]; then + bad "validate-kits.py not found at $VALIDATOR" + else + skip "validate-kits.py not found at $VALIDATOR" + fi +elif ! python3 -c 'import jsonschema, yaml' >/dev/null 2>&1; then + if [ "$validator_required" -eq 1 ]; then + bad "validate-kits.py needs jsonschema + pyyaml" + else + skip "validate-kits.py needs jsonschema + pyyaml; ALLOW_PARTIAL_VERIFY=1 permits this skip" + fi +else + if val_out="$(python3 "$VALIDATOR" --root "$REPO_ROOT" --strict 2>&1)"; then + ok "validate-kits.py --strict passes" + else + bad "validate-kits.py --strict failed" + printf '%s\n' "$val_out" | sed 's/^/ | /' >&2 + fi +fi + +for script in "$KIT_DIR/files/home/opendesign-install.sh" "$KIT_DIR/files/home/opendesign-start.sh"; do + if sh -n "$script"; then + ok "$(basename "$script") parses with sh -n" + else + bad "$(basename "$script") has shell syntax errors" + fi +done + +RELAY="$KIT_DIR/files/home/opendesign-relay.mjs" +if [ ! -f "$RELAY" ]; then + bad "opendesign-relay.mjs missing (required by the loopback-bind decision)" +elif command -v node >/dev/null 2>&1; then + if node --check "$RELAY" >/dev/null 2>&1; then + ok "opendesign-relay.mjs parses with node --check" + else + bad "opendesign-relay.mjs has syntax errors" + fi + if node "$RELAY" --self-test >/dev/null 2>&1; then + ok "opendesign-relay.mjs route, peer gate, and bind-selection self-tests pass" + else + bad "opendesign-relay.mjs route, peer gate, or bind-selection self-test fails" + fi +else + skip "opendesign-relay.mjs not syntax-checked (node not on PATH)" + skip "opendesign-relay.mjs self-test not run (node not on PATH)" +fi + +spec="$(cat "$KIT_DIR/spec.yaml")" +install_src="$(cat "$KIT_DIR/files/home/opendesign-install.sh")" +start_src="$(cat "$KIT_DIR/files/home/opendesign-start.sh")" +relay_src="$(cat "$RELAY" 2>/dev/null || true)" +case "$spec" in + *'OD_DISABLE_API_AUTH: "1"'*) ok "spec disables OpenDesign API auth intentionally" ;; + *) bad "spec does not set OD_DISABLE_API_AUTH=1" ;; +esac +case "$spec" in + *'guest: 7456'*) ok "spec publishes guest port 7456" ;; + *) bad "spec does not publish guest port 7456" ;; +esac +# The daemon must NOT bind 0.0.0.0: upstream requireLocalDaemonRequest gates +# /api/diagnostics/export and /api/strategies/od-next/rollout on a loopback peer +# address, so a guest-network peer gets 403. Reachability comes from the relay. +# See docs/decisions/disable-api-auth-loopback-boundary.md. +case "$spec" in + *'OD_BIND_HOST: "127.0.0.1"'*) ok "spec binds the daemon to guest loopback" ;; + *) bad "spec does not bind the daemon to guest loopback (peer-loopback-gated routes will 403)" ;; +esac +case "$spec" in + *'source: files/home/opendesign-relay.mjs'*) ok "spec installs the loopback publish relay" ;; + *) bad "spec does not install opendesign-relay.mjs" ;; +esac +case "$start_src" in + *'OD_BIND_HOST="${OPENDESIGN_DAEMON_BIND_HOST:-127.0.0.1}"'*) ok "startup forces the daemon bind to loopback unless the kit-specific override is set" ;; + *) bad "startup can inherit OD_BIND_HOST instead of forcing loopback" ;; +esac +case "$start_src" in + *'OPENDESIGN_PUBLISHED_PORT="${OPENDESIGN_PUBLISHED_PORT:-7456}"'*'OD_PORT="${OPENDESIGN_DAEMON_PORT:-17456}"'*) ok "startup separates published relay port 7456 from daemon port 17456" ;; + *) bad "startup does not separate relay and daemon ports" ;; +esac +case "$start_src" in + *'supervisor:opendesign-relay'*) ok "startup supervises the publish relay" ;; + *) bad "startup does not supervise the publish relay" ;; +esac +# A relay that rewrote or parsed traffic would break SSE run streams; keep it a +# byte pipe that only forwards to loopback. +case "$relay_src" in + *"TARGET_HOST = '127.0.0.1'"*) ok "relay forwards to loopback only" ;; + *) bad "relay does not forward to loopback only" ;; +esac +case "$relay_src" in + *'localAddress: TARGET_HOST'*) ok "relay originates upstream connections from loopback" ;; + *) bad "relay does not originate upstream connections from loopback" ;; +esac +case "$relay_src" in + *'entry.internal'*) ok "relay skips loopback interfaces so it cannot collide with the daemon" ;; + *) bad "relay does not skip loopback interfaces" ;; +esac +case "$relay_src" in + *'publishableAddresses(route = defaultIpv4Route(), interfaces = os.networkInterfaces())'*'Object.entries(interfaces)'*'name !== route.iface'*) ok "relay binds only the default-route guest interface" ;; + *) bad "relay can bind non-default guest interfaces" ;; +esac +case "$relay_src" in + *'OPENDESIGN_RELAY_ALLOWED_PEERS'*'allowedPeerAddresses(route = defaultIpv4Route()'*'route.gateway'*'isAllowedPeer(peer)'*) ok "relay restricts client peers to loopback/default gateway plus explicit overrides" ;; + *) bad "relay does not restrict client peers before forwarding" ;; +esac +case "$relay_src" in + *'metric >= selected.metric'*) ok "relay selects the lowest-metric active default route" ;; + *) bad "relay does not select the lowest-metric active default route" ;; +esac +# OpenDesign rewrites HOME/XDG_CONFIG_HOME for agent children (OD_SANDBOX_MODE), +# so OpenCode needs an absolute config path or it silently falls back to its own +# hosted gateway and every run fails. See docs/decisions/acq-owned-byok.md. +case "$start_src" in + *'export OPENCODE_CONFIG='*) ok "startup names the OpenCode global config for sandbox-mode children" ;; + *) bad "startup does not export OPENCODE_CONFIG (OpenDesign runs will not reach USAi)" ;; +esac +case "$start_src" in + *'opencode/opencode.jsonc'*'OPENCODE_CONFIG="$candidate"'*) ok "startup resolves OPENCODE_CONFIG from the usai-provider config path" ;; + *) bad "startup does not resolve OPENCODE_CONFIG from the usai-provider config path" ;; +esac +case "$spec" in + *'/home/agent/.local/share/opendesign'*) ok "spec declares the OpenDesign persistent volume/data path" ;; + *) bad "spec does not declare the OpenDesign persistent volume/data path" ;; +esac +case "$spec" in + *'user: "0"'*'chown 1000:1000 /home/agent/.local/share/opendesign'*) ok "spec initializes persistent volume ownership as root before agent startup" ;; + *) bad "spec does not initialize persistent volume ownership before agent startup" ;; +esac +case "$install_src" in + *'npm_config_store_dir="$APP_HOME/pnpm-store"'*) ok "install script pins pnpm store to the OpenDesign volume" ;; + *) bad "install script does not pin pnpm store to the OpenDesign volume" ;; +esac +case "$install_src" in + *'node-v${OPENDESIGN_NODE_VERSION}-linux-${node_arch}.tar.gz'*|*'node-v${OPENDESIGN_NODE_VERSION}-linux-'*) ok "install script uses a pinned kit-managed Node archive" ;; + *) bad "install script does not use a kit-managed Node archive" ;; +esac +case "$install_src" in + *'sha256sum'*'node_sha'*) ok "install script verifies Node archive SHA-256" ;; + *) bad "install script does not verify Node archive SHA-256" ;; +esac +case "$install_src" in + *'git checkout --detach FETCH_HEAD'*'OPENDESIGN_COMMIT'*) ok "install script verifies pinned OpenDesign commit" ;; + *) bad "install script does not verify pinned OpenDesign commit" ;; +esac +case "$install_src" in + *'base64 -d >"$proxy_ca_tmp"'*'cat "$proxy_ca_tmp" >>"$ca_bundle"'*'ignoring invalid PROXY_CA_CERT_B64'*) ok "install script validates proxy CA before appending it" ;; + *) bad "install script can append invalid proxy CA data to NODE_EXTRA_CA_CERTS" ;; +esac +case "$start_src" in + *'refresh PATH before seeding config or launching the daemon'*'export PATH'*) ok "startup refreshes PATH after installing kit-managed Node" ;; + *) bad "startup does not refresh PATH after installing kit-managed Node" ;; +esac +case "$start_src" in + *"config.agentId = 'opencode'"*) ok "startup seeds OpenDesign default agent to opencode" ;; + *) bad "startup does not seed opencode as default agent" ;; +esac +case "$start_src" in + *agentModels*|*'model":'*|*"model':"*) bad "startup appears to seed a model, which v1 must not do" ;; + *) ok "startup does not seed a default model" ;; +esac +case "$start_src" in + *USAI_API_KEY*'media-config'*|*media-config*'USAI_API_KEY'*) bad "startup appears to copy USAI_API_KEY into OpenDesign media config" ;; + *) ok "startup does not copy USAi credentials into OpenDesign config" ;; +esac + +if [ "$RUN_ACQ" != "1" ]; then + info "Live sandbox checks: SKIPPED" + echo " Set RUN_ACQ=1 to create a real sandbox and verify OpenDesign end-to-end." >&2 + info "Summary" + printf ' %d passed, %d failed, %d skipped\n' "$pass" "$fail" "$skip_count" + if [ "$fail" -eq 0 ] && [ "$skip_count" -eq 0 ]; then + printf ' \033[32mOffline gate passed.\033[0m\n' + elif [ "$fail" -eq 0 ]; then + printf ' \033[33mPartial offline gate passed; skipped checks were not required in this mode.\033[0m\n' + else + printf ' \033[31mOffline gate failed.\033[0m\n' + fi + exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" +fi + +info "2. Live sandbox create" +if ! command -v acq >/dev/null 2>&1; then + echo " RUN_ACQ=1 set but acq is not on PATH." >&2 + exit 1 +fi + +echo " creating $SBX_NAME via acq with extra kit $KIT_DIR..." +# Live verification injects a synthetic value through the process environment so +# the negative persistence check can search for a known non-secret sentinel. The +# documented operational path remains `acq secret set-custom ... --env USAI_API_KEY`. +if USAI_API_KEY="$SENTINEL_SECRET" ACQ_EXTRA_KITS="$KIT_DIR" acq create --name "$SBX_NAME" opencode "$WORK" >"$CREATE_LOG" 2>&1; then + ok "sandbox created via acq" +else + bad "acq create failed" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 +fi + +info "3. Wait for OpenDesign daemon" +start_time="$(date +%s)" +deadline=$(( start_time + READY_TIMEOUT )) +next_progress=$(( start_time + 30 )) +startup_checked=0 +health="" +while :; do + now="$(date +%s)" + if [ "$(in_acq 'curl -fsS http://127.0.0.1:17456/api/daemon/status >/dev/null 2>&1 && curl -fsS http://127.0.0.1:17456/ >/dev/null 2>&1 && echo yes')" = "yes" ]; then + health=yes + break + fi + + if [ "$now" -ge "$next_progress" ]; then + elapsed=$(( now - start_time )) + printf ' still waiting after %ss; latest sandbox state:\n' "$elapsed" + in_acq 'stat -c "%U:%G %a %n" /home/agent/.local/share/opendesign /home/agent/.local/state/opendesign 2>/dev/null; ps -ef | grep -E "opendesign|node|pnpm|next|tsc|git|curl" | grep -v grep || true; tail -n 20 ~/.local/state/opendesign/opendesign-install.log 2>/dev/null || true; tail -n 20 ~/.local/state/opendesign/opendesign-daemon.log 2>/dev/null || true' | sed 's/^/ | /' + next_progress=$(( now + 30 )) + fi + + if [ "$startup_checked" -eq 0 ] && [ $(( now - start_time )) -ge 60 ]; then + startup_checked=1 + startup_state="$(in_acq 'test -s ~/.local/state/opendesign/opendesign-install.log || test -s ~/.local/state/opendesign/opendesign-daemon.log || ps -ef | grep -E "opendesign|node|pnpm|next|tsc|git|curl" | grep -v grep >/dev/null; rc=$?; test "$rc" -eq 0 && echo active || echo inactive')" + if [ "$startup_state" = "inactive" ]; then + bad "OpenDesign startup produced no logs and no active install/daemon process after 60s" + in_acq 'stat -c "%U:%G %a %n" /home/agent/.local/share/opendesign /home/agent/.local/state/opendesign 2>/dev/null; ls -l /home/agent/opendesign-start.sh /home/agent/opendesign-install.sh 2>/dev/null; ls -la ~/.local/state/opendesign ~/.local/share/opendesign 2>/dev/null' | sed 's/^/ | /' + break + fi + fi + + [ "$now" -ge "$deadline" ] && break + sleep "$POLL_INTERVAL" +done + +if [ -n "$health" ]; then + ok "OpenDesign answers /api/daemon/status and serves the UI inside the sandbox" +else + bad "OpenDesign did not answer /api/daemon/status and serve the UI before timeout" + in_acq 'tail -n 80 ~/.local/state/opendesign/opendesign-install.log 2>/dev/null; tail -n 80 ~/.local/state/opendesign/opendesign-daemon.log 2>/dev/null' | sed 's/^/ | /' +fi + +info "4. Live configuration checks" +[ "$(in_acq 'command -v opencode >/dev/null 2>&1 && echo yes')" = "yes" ] && ok "opencode is on PATH" || bad "opencode is not on PATH" +# Assert only the fields the kit actually seeds. `agentModels` is deliberately +# excluded: the kit-must-not-seed-a-model invariant is proven offline against the +# startup source ("startup does not seed a default model"), while the live file is +# runtime state OpenDesign owns and rewrites. Merely opening the UI is enough for +# OpenDesign to persist agentModels.opencode.model (observed: "default", written +# by the app, not by a user), alongside orbit/projectLocations/privacyDecisionAt. +# Failing on that conflates "the kit seeded a model" with "a model is configured" +# and would report a defect for normal use. +app_config_ok="$(in_acq 'node -p '\''const fs=require("fs"); const c=JSON.parse(fs.readFileSync((process.env.OD_DATA_DIR||"")+"/app-config.json","utf8")); const t=c.telemetry||{}; (c.agentId==="opencode" && c.onboardingCompleted===true && t.metrics===false && t.content===false) ? "yes" : "no"'\'' 2>/dev/null')" +if [ "$app_config_ok" = "yes" ]; then + ok "OpenDesign app-config seeded opencode and disabled telemetry" +else + bad "OpenDesign app-config was not seeded as expected" + in_acq 'cat "$OD_DATA_DIR/app-config.json" 2>/dev/null || echo "missing app-config.json"' | sed 's/^/ | /' +fi +# Report, without judging, whether a model ended up configured. On a freshly +# created sandbox that nobody has driven yet this should be empty; anything else +# is information for the operator, not a kit failure. +seeded_models="$(in_acq 'node -p '\''const fs=require("fs"); const c=JSON.parse(fs.readFileSync((process.env.OD_DATA_DIR||"")+"/app-config.json","utf8")); JSON.stringify(c.agentModels||{})'\'' 2>/dev/null')" +if [ -z "$seeded_models" ] || [ "$seeded_models" = "{}" ]; then + ok "no agent model is configured in app-config (kit seeded none)" +else + skip "a model is configured in app-config: $seeded_models (runtime state, not kit-seeded; see the offline no-seed check)" +fi +[ "$(in_acq 'test "$OD_DISABLE_API_AUTH" = 1 && echo yes')" = "yes" ] && ok "OD_DISABLE_API_AUTH is set" || bad "OD_DISABLE_API_AUTH is not set" +[ "$(in_acq 'test -n "$USAI_API_KEY" && grep -R -F -- "$USAI_API_KEY" "$OD_DATA_DIR" >/dev/null 2>&1; rc=$?; test "$rc" -eq 1 && echo yes')" = "yes" ] \ + && ok "OD_DATA_DIR does not contain the injected USAI_API_KEY value" \ + || bad "OD_DATA_DIR contains the injected USAI_API_KEY value or grep failed unexpectedly" + +# 4430 hex = 17456 (loopback-only daemon). 1D20 hex = 7456 (published relay). +# Keeping these separate prevents ACQ from bypassing the relay and reaching a +# same-port daemon directly. +daemon_bind="$(in_acq 'awk "\$4 == \"0A\" && \$2 ~ /:4430$/ { print \$2 }" /proc/net/tcp | sort -u | tr "\n" " "')" +relay_bind="$(in_acq 'awk "\$4 == \"0A\" && \$2 ~ /:1D20$/ { print \$2 }" /proc/net/tcp | sort -u | tr "\n" " "')" +case "$daemon_bind" in + *0100007F:4430*) ok "a loopback daemon listener holds internal port 17456 in-guest" ;; + *) bad "no loopback daemon listener on internal port 17456 in-guest (saw: ${daemon_bind:-none})" ;; +esac +case "$daemon_bind $relay_bind" in + *00000000:4430*|*00000000:1D20*) bad "OpenDesign ports are bound to 0.0.0.0; peer-loopback-gated routes may 403 from the host" ;; + *) ok "OpenDesign ports are not bound to 0.0.0.0" ;; +esac +case "$relay_bind" in + *:1D20*) ok "publish relay holds guest port 7456 in-guest" ;; + *) bad "publish relay does not hold guest port 7456 in-guest (saw: ${relay_bind:-none})" ;; +esac +# Check the relay's own node process, not the supervisor wrapper: the node +# process is what actually holds the published port. +# +# The bracketed first character is load-bearing. `acq exec` runs this check in a +# shell whose own cmdline contains the pattern text, and `pgrep -f` matches +# against every process's full cmdline — including that shell's. A plain +# "opendesign-relay" pattern therefore reports "running" even when no relay +# exists anywhere, which is a false PASS on exactly the failure this assertion +# is here to catch. `[o]pendesign` does not match the literal text +# "[o]pendesign" in the checking shell's cmdline, but does match the real +# process's "opendesign-relay.mjs". Verified in both directions (relay absent / +# relay present, bare and under its supervisor). +if [ "$(in_acq 'pgrep -f "[o]pendesign-relay\.mjs" >/dev/null 2>&1 && echo yes')" = "yes" ]; then + ok "publish relay is running in-guest" +else + bad "publish relay is not running in-guest" + in_acq 'tail -n 40 ~/.local/state/opendesign/opendesign-relay.log 2>/dev/null || echo "missing opendesign-relay.log"' | sed 's/^/ | /' +fi + +# OpenDesign sandbox-mode rewrites HOME/XDG_CONFIG_HOME for agent children, so +# without OPENCODE_CONFIG OpenCode reads an empty stub config and falls back to +# its own hosted gateway, which this kit's egress policy blocks. Assert that the +# named config is the usai-provider one AND that OpenCode resolves usai models +# through it in the environment OpenDesign will actually launch. +# +# Resolving the daemon pid needs two guards, both found by running this against a +# live sandbox rather than reasoning about it: +# +# 1. Anchor on argv[0] being node. The daemon's supervisor shell carries the +# same "apps/daemon/dist/cli.js" string later in its own cmdline, so an +# unanchored pattern can resolve to the supervisor's pid and read the wrong +# process's environ. The shell does inherit the exports, so that reads as a +# pass — a wrong-target read that happens to agree, which is worse than a +# failure because it hides the mistake. +# 2. Select by OD_PORT rather than taking the first match. A host may have more +# than one daemon alive (a leftover, or a second sandbox), and "first pid +# wins" silently inspects whichever one sorts lowest. Matching internal daemon +# port 17456 makes the check say what it means. +daemon_pid_cmd='for p in $(pgrep -f "^[^ ]*node .*apps/daemon/dist/cli\.js"); do [ -r "/proc/$p/environ" ] || continue; got=$(tr "\0" "\n" < "/proc/$p/environ" | sed -n "s/^OD_PORT=//p"); [ "$got" = "17456" ] || continue; echo "$p"; break; done' +opencode_config="$(in_acq "pid=\$($daemon_pid_cmd); [ -n \"\$pid\" ] && tr '\\0' '\\n' < \"/proc/\$pid/environ\" | sed -n 's/^OPENCODE_CONFIG=//p'")" +if [ -n "$opencode_config" ]; then + ok "daemon process carries OPENCODE_CONFIG=$opencode_config" +else + bad "daemon process does not carry OPENCODE_CONFIG; OpenDesign runs will not reach USAi" +fi +if [ "$(acq exec "$SBX_NAME" -- env "OPENCODE_CONFIG_PATH=$opencode_config" sh -c 'test -r "$OPENCODE_CONFIG_PATH" && grep -q "api.gsa.usai.gov" "$OPENCODE_CONFIG_PATH" && echo yes' /dev/null || true)" = "yes" ]; then + ok "the named OpenCode config points at the USAi gateway" +else + skip "the daemon-named OpenCode config is unreadable or does not mention api.gsa.usai.gov (usai-provider may not be paired)" +fi +usai_models="$(acq exec "$SBX_NAME" -- env "OPENCODE_CONFIG_PATH=$opencode_config" sh -c 'OPENCODE_CONFIG="$OPENCODE_CONFIG_PATH" HOME="$OD_DATA_DIR/sandbox/agent-home" XDG_CONFIG_HOME="$OD_DATA_DIR/sandbox/config" XDG_DATA_HOME="$OD_DATA_DIR/sandbox/config/data" XDG_CACHE_HOME="$OD_DATA_DIR/sandbox/cache" timeout 60 opencode models 2>/dev/null | grep -c "^usai/"' /dev/null || true)" +if [ "${usai_models:-0}" -gt 0 ] 2>/dev/null; then + ok "OpenCode resolves $usai_models usai/* models under OpenDesign's sandbox-mode env" +else + bad "OpenCode resolves no usai/* models under OpenDesign's sandbox-mode env (runs would fall back to the hosted gateway)" +fi + +info "5. Host port published" +ports_out="$(acq ports "$SBX_NAME" 2>/dev/null || true)" +if printf '%s' "$ports_out" | grep -q 7456; then + ok "acq ports reports a mapping for guest 7456" +else + bad "acq ports does not report guest 7456" +fi +printf '%s\n' "$ports_out" | sed 's/^/ | /' + +host_url="$(printf '%s\n' "$ports_out" | python3 -c 'import re, sys +for line in sys.stdin: + match = re.search(r"sandbox\s+7456\s*->\s*host\s+(127\.0\.0\.1|localhost):(\d+)", line) + if match: + print(f"http://{match.group(1)}:{match.group(2)}") + break +')" +if [ -n "$host_url" ]; then + host_status_body="$WORK/daemon-status-host.json" + if curl -fsS "$host_url/" >/dev/null 2>&1 && curl -fsS "$host_url/api/daemon/status" -o "$host_status_body" >/dev/null 2>&1; then + ok "published host-loopback URL is reachable: $host_url" + sed 's/^/ | daemon-status: /' "$host_status_body" 2>/dev/null || true + else + bad "published host-loopback URL is not reachable: $host_url" + fi + # Regression gates for the loopback-peer bug: these are browser-visible routes + # guarded by requireLocalDaemonRequest. They returned 403 whenever the daemon + # observed a non-loopback peer through create-time port publishing. + rollout_body="$WORK/od-next-rollout.json" + rollout_code="$(curl -s -o "$rollout_body" -w '%{http_code}' --max-time 30 -X POST \ + -H "Origin: $host_url" -H 'Content-Type: application/json' --data '{}' \ + "$host_url/api/strategies/od-next/rollout" 2>/dev/null || true)" + if [ "$rollout_code" = "200" ]; then + ok "OD Next rollout route is reachable from the host browser path (HTTP 200)" + else + bad "OD Next rollout route returned HTTP ${rollout_code:-none} from the host" + sed 's/^/ | /' "$rollout_body" 2>/dev/null || true + in_acq 'tail -n 60 ~/.local/state/opendesign/opendesign-relay.log 2>/dev/null || true' | sed 's/^/ | relay: /' + fi + + export_body="$WORK/diagnostics-export.body" + export_code="$(curl -s -o "$export_body" -w '%{http_code}' --max-time 120 \ + -H "Origin: $host_url" "$host_url/api/diagnostics/export" 2>/dev/null || true)" + if [ "$export_code" = "200" ]; then + ok "diagnostics export is reachable from the host browser path (HTTP 200)" + else + bad "diagnostics export returned HTTP ${export_code:-none} from the host" + sed 's/^/ | /' "$export_body" 2>/dev/null || true + in_acq 'tail -n 60 ~/.local/state/opendesign/opendesign-relay.log 2>/dev/null || true' | sed 's/^/ | relay: /' + fi +else + bad "could not parse host-loopback URL from acq ports output" +fi + +info "Summary" +printf ' %d passed, %d failed, %d skipped\n' "$pass" "$fail" "$skip_count" +[ "$fail" -eq 0 ] && printf ' \033[32mAll required checks passed.\033[0m\n' || printf ' \033[31mSome checks failed.\033[0m\n' +[ "$fail" -eq 0 ] diff --git a/integrations/isolation/acq-kits/opendesign/spec.yaml b/integrations/isolation/acq-kits/opendesign/spec.yaml new file mode 100644 index 00000000..d77f42b1 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/spec.yaml @@ -0,0 +1,174 @@ +# spec.yaml — opendesign (a neutral acq mixin kit, hybrid/v1) +# +# Runs OpenDesign (https://github.com/nexu-io/open-design) headlessly inside an +# acq sandbox and publishes its browser UI / daemon to the host. OpenDesign is a +# local-first design workspace that launches existing agent CLIs; this kit does +# NOT install OpenCode or own model-provider credentials. It composes with the +# usai-provider kit, which injects USAI_API_KEY and configures OpenCode. +# +# DESIGN: +# - Startup, not create-time install. OpenDesign is built from source, needs +# Node 24 + pnpm 10.33.2, and may compile native dependencies. A transient +# npm/GitHub/proxy failure should make the UI unavailable, not kill sandbox +# creation. The startup script is fail-soft and supervised. +# - Kit-managed Node 24. The OpenCode sandbox base may carry another Node major, +# so this kit downloads a pinned Linux Node 24 archive into its persistent +# volume and verifies SHA-256 before use. +# - OpenDesign runs as a headless daemon bound to guest loopback on internal +# port 17456: `od --host 127.0.0.1 --port 17456 --no-open`. A small +# kit-managed relay (opendesign-relay.mjs) binds the published guest network +# port 7456 and forwards to that loopback listener, because msb/acq +# create-time port publishing dials the +# guest network IP rather than guest 127.0.0.1. Binding the daemon itself to +# 0.0.0.0 would make it observe a non-loopback PEER address for every request +# arriving from the host, and upstream's `requireLocalDaemonRequest` guard +# then answers 403 on routes the browser UI calls directly — including +# Settings -> About -> Export diagnostics. See +# docs/decisions/disable-api-auth-loopback-boundary.md. +# - Authentication is disabled with OD_DISABLE_API_AUTH=1. The sandbox boundary +# and host-loopback port are the control, matching the approved posture for +# this kit and the sibling paseo/openchamber UI kits. +# - OpenCode's global config is named to OpenDesign-launched runs via +# OPENCODE_CONFIG. OpenDesign sets OD_SANDBOX_MODE=1, which rewrites HOME and +# XDG_CONFIG_HOME for agent children, so OpenCode would otherwise never read +# the usai-provider kit's config and would fall back to its own hosted +# gateway. This names a config PATH only; no key material is copied. +# - OD_DATA_DIR lives on a persistent kit volume. BYOK/model credentials should +# continue to flow through acq + usai-provider + OpenCode, not through files in +# this kit. +schemaVersion: "hybrid/v1" +kind: mixin +name: opendesign +displayName: OpenDesign (headless browser design workspace) +description: > + Runs OpenDesign headlessly inside the sandbox as a browser-accessible local + design workspace. Installs a pinned OpenDesign source checkout with a + kit-managed Node 24 runtime, publishes the OpenDesign daemon/UI on port 7456, + persists OpenDesign state on a kit volume, and seeds OpenDesign to use the + existing OpenCode CLI. Opt-in mixin; designed to compose with usai-provider + for model access and BYOK credential injection. + +caps: + network: + allow: + # OpenDesign source checkout and release metadata. + - github.com:443 + - codeload.github.com:443 + - objects.githubusercontent.com:443 + - release-assets.githubusercontent.com:443 + # Node 24 tarball + checksum list. + - nodejs.org:443 + # pnpm/npm package metadata and tarballs. Avoid leading-* wildcard entries: + # neutral->backend translation may emit YAML that treats them as aliases. + - registry.npmjs.org:443 + +files: + - path: /home/agent/opendesign-install.sh + mode: "0755" + source: files/home/opendesign-install.sh + description: Install/update pinned OpenDesign source checkout and kit-managed Node 24 runtime + - path: /home/agent/opendesign-start.sh + mode: "0755" + source: files/home/opendesign-start.sh + description: Install first boot, seed OpenDesign app config, and supervise the daemon/UI + - path: /home/agent/opendesign-relay.mjs + mode: "0644" + source: files/home/opendesign-relay.mjs + description: Forward the published guest-network port to the loopback-bound daemon so peer-loopback-gated routes work + +commands: + - phase: startup + user: "0" + background: false + description: Ensure OpenDesign persistent volume is writable by the agent user + command: + - sh + - -c + - | + mkdir -p /home/agent/.local/share/opendesign /home/agent/.local/state/opendesign + chown 1000:1000 /home/agent/.local/share/opendesign /home/agent/.local/state/opendesign + - phase: startup + user: "1000" + background: true + description: Install/update OpenDesign and supervise the headless daemon/UI (background) + command: + - sh + - -c + - | + OPENDESIGN_REF="open-design-v0.24.0" \ + OPENDESIGN_COMMIT="0d3a14c1df6dc5017f3cc3ef05b24558250c220b" \ + OPENDESIGN_NODE_VERSION="24.21.0" \ + OPENDESIGN_PNPM_VERSION="10.33.2" \ + sh /home/agent/opendesign-start.sh & + +publishedPorts: + - guest: 7456 + protocol: tcp + name: opendesign + +volumes: + - path: /home/agent/.local/share/opendesign + size: "10G" + +environment: + # Loopback-only. opendesign-relay.mjs publishes this on the guest network + # address; see the DESIGN note above and the ADR for why the daemon itself + # must not bind 0.0.0.0. + OD_BIND_HOST: "127.0.0.1" + OPENDESIGN_PUBLISHED_PORT: "7456" + OPENDESIGN_DAEMON_PORT: "17456" + OD_PORT: "17456" + OD_WEB_PORT: "7456" + OD_DATA_DIR: "/home/agent/.local/share/opendesign/data" + OD_DISABLE_API_AUTH: "1" + OD_SANDBOX_MODE: "1" + +agentContext: | + ## OpenDesign (headless browser design workspace) + + This sandbox runs **OpenDesign** in the background on container port **7456**. + OpenDesign is a browser UI and local daemon for design work; it launches + existing agent CLIs from the sandbox, including OpenCode. The kit does not + install OpenCode and does not store model-provider credentials. + + **Open it from the host.** The kit publishes container port 7456. Find the + mapped host-loopback port and open the browser: + + ``` + acq ports + # OpenDesign UI: open http://localhost: + ``` + + **Model credentials.** Pair this kit with `usai-provider` for the GSA default + path. `usai-provider` configures OpenCode to use USAi, and `acq` injects + `USAI_API_KEY` at runtime. Startup exports `OPENCODE_CONFIG` pointing at that + kit's `~/.config/opencode/opencode.jsonc`, because OpenDesign's sandbox mode + rewrites `HOME`/`XDG_CONFIG_HOME` for agent children and OpenCode would + otherwise fall back to its own hosted gateway and fail every run. Do not paste + provider keys into chat. If you choose to configure extra OpenDesign media + providers through the UI, those settings are stored under the persistent + OpenDesign data directory. + + **Default agent.** Startup seeds OpenDesign's app config to select OpenCode, + complete onboarding, and disable local telemetry when no user choice exists. It + deliberately does NOT seed a default model; OpenCode/OpenDesign should use + their configured defaults. + + **Auth.** The OpenDesign daemon runs with `OD_DISABLE_API_AUTH=1`. The sandbox + is the security boundary and the host port is loopback-only. Run this only on a + trusted, single-user host and do not forward the mapped port. + + **Networking.** The daemon binds guest `127.0.0.1:17456` only; a supervised + relay (`~/opendesign-relay.mjs`) forwards published guest port `7456` to it. + This is required because OpenDesign gates some routes on the request peer being + loopback, and would otherwise return 403 for the browser (visibly: Settings -> + About -> Export diagnostics). If a UI action returns 403, check the relay log + before anything else. + + Logs: `~/.local/state/opendesign/opendesign-daemon.log`, + `~/.local/state/opendesign/opendesign-relay.log`, and + `~/.local/state/opendesign/opendesign-install.log`. + +# No backend_shortcuts / backend_extras: the port, volume, files, env, and +# background startup are declared with the neutral hybrid/v1 vocabulary and are +# translated by acq for the active backend. diff --git a/integrations/isolation/acq-kits/pi-coding-agent/README.md b/integrations/isolation/acq-kits/pi-coding-agent/README.md index b60f6da5..840e9e2a 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/README.md +++ b/integrations/isolation/acq-kits/pi-coding-agent/README.md @@ -13,12 +13,11 @@ 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. +This kit ships **ahead of** the in-flight `prime-agent` kit 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 work remains open, not abandoned. ## Status 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 d6b24068..39b93f0e 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 @@ -119,12 +119,12 @@ 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 +- `../../files/home/pi-coding-agent-install.sh` — the install-time CA-bundle block this decision's boot-time wrapper rewrite parallels. - [`local-prefix-not-npm-global.md`](local-prefix-not-npm-global.md) — why the wrapper lives at `$HOME/.local/bin/pi` specifically. -- `../../openchamber/docs/decisions/wrapper-entrypoint-owns-server.md` — the +- `../../../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 +- `../../../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 index 67aaf55d..e7fc184e 100644 --- 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 @@ -96,12 +96,12 @@ contrasting the two mechanisms. The reasoning: ## Links -- `../../openchamber/scripts/verify` — `in_sbx`'s own comment explaining why +- `../../../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 +- `../../../paseo/scripts/verify` — the same workaround, independently duplicated. -- `../../openchamber/docs/decisions/wrapper-entrypoint-owns-server.md` — the +- `../../../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 diff --git a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml index 8b921697..421a6c99 100644 --- a/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml +++ b/integrations/isolation/acq-kits/pi-coding-agent/spec.yaml @@ -13,13 +13,13 @@ # 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. +# AHEAD of the in-flight prime-agent kit 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