From c76b711c17c203257fc0ddc23d227f8a87406a66 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 01:02:56 +0000 Subject: [PATCH 01/10] feat(acq-kits): add OpenDesign browser kit Add an opt-in OpenDesign ACQ kit that runs the headless browser workspace inside the sandbox, reuses the existing OpenCode CLI, and keeps model credentials on the ACQ/OpenCode path. Includes pinned source install, kit-managed Node 24, persistent OpenDesign state, startup supervision, live/offline verification, troubleshooting, and design rationale. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/README.md | 1 + integrations/isolation/acq-kits/kits.yaml | 16 ++ .../isolation/acq-kits/opendesign/README.md | 170 ++++++++++++ .../acq-kits/opendesign/TROUBLESHOOTING.md | 209 ++++++++++++++ .../docs/decisions/acq-owned-byok.md | 41 +++ .../disable-api-auth-loopback-boundary.md | 36 +++ .../source-install-headless-daemon.md | 51 ++++ .../files/home/opendesign-install.sh | 136 +++++++++ .../opendesign/files/home/opendesign-start.sh | 132 +++++++++ .../acq-kits/opendesign/scripts/verify | 259 ++++++++++++++++++ .../isolation/acq-kits/opendesign/spec.yaml | 140 ++++++++++ 11 files changed, 1191 insertions(+) create mode 100644 integrations/isolation/acq-kits/opendesign/README.md create mode 100644 integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md create mode 100644 integrations/isolation/acq-kits/opendesign/docs/decisions/acq-owned-byok.md create mode 100644 integrations/isolation/acq-kits/opendesign/docs/decisions/disable-api-auth-loopback-boundary.md create mode 100644 integrations/isolation/acq-kits/opendesign/docs/decisions/source-install-headless-daemon.md create mode 100755 integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh create mode 100755 integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh create mode 100755 integrations/isolation/acq-kits/opendesign/scripts/verify create mode 100644 integrations/isolation/acq-kits/opendesign/spec.yaml diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index 6dd74ee6..7a91a57e 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -24,6 +24,7 @@ not agent behavior. (Behavioral patterns live in `skills/`, `prompts/`, etc.) | [`git-ssh-sign/`](git-ssh-sign/) | Sign git commits and tags with the SSH key forwarded from the host agent (vendored from sbx-kits-contrib). | | [`openchamber/`](openchamber/) | Run OpenChamber, a browser UI for OpenCode, inside the sandbox alongside the terminal TUI. Opt-in (see its parity note). | | [`paseo/`](paseo/) | Self-host the [Paseo](https://github.com/getpaseo/paseo) daemon + browser web UI (for coding agents) inside the sandbox on one port. Opt-in; agent-generic wrapper, no shared TUI session. | +| [`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. | Each kit is self-contained: a `spec.yaml` (`hybrid/v1`), any `files/` payload, a `scripts/verify` host-side check, a `README.md` (with a **backend parity** note), diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 2805e347..0c1da45a 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -80,6 +80,22 @@ 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. + prime-agent: backends: [sbx, msb] parity: | diff --git a/integrations/isolation/acq-kits/opendesign/README.md b/integrations/isolation/acq-kits/opendesign/README.md new file mode 100644 index 00000000..27cb1a03 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/README.md @@ -0,0 +1,170 @@ +# 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. + +A single OpenDesign daemon serves the API and browser UI on container port +**7456**. + +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 `0.0.0.0:7456` with `--no-open`. +- 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. + +The kit deliberately does **not** seed a default model. OpenCode/OpenDesign use +their existing configured defaults. + +## 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`. + +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: +``` + +Want a fixed host port instead of the ephemeral one? + +```bash +acq ports --publish 7456:7456 +``` + +## Security + +The daemon runs with `OD_DISABLE_API_AUTH=1` and binds `0.0.0.0` inside the +sandbox. 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. + +The in-guest `0.0.0.0` bind is required for `acq` create-time port publishing on +msb-style backends: the host listener dials the sandbox guest network IP, not +guest `127.0.0.1`. + +## 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 also appends `PROXY_CA_CERT_B64` and 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. Needs python3 + jsonschema + pyyaml. +python ../validate-kits.py + +# Kit-local offline checks. +./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. + +## Layout + +```text +opendesign/ +├── spec.yaml # kit declaration +├── files/home/ +│ ├── opendesign-install.sh # Node 24 + OpenDesign source install/build +│ └── opendesign-start.sh # app-config seed + daemon supervisor +├── 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..1547419e --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md @@ -0,0 +1,209 @@ +# Troubleshooting — opendesign acq kit + +Symptoms, causes, and fixes for the OpenDesign headless browser kit. Most +diagnosis is done with `acq exec -- sh -c '…'`. + +--- + +## 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:7456/api/daemon/status && echo OK' +``` + +- `/api/daemon/status` returns 200 and the port is mapped: open + `http://localhost:`. +- `/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' + ``` + +## 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 'PATH="$HOME/.local/share/opendesign/tools/node-v24.21.0-linux-arm64/bin:$PATH" node -p "process.version + " " + process.versions.modules"' +``` + +## Host curl returns "Empty reply from server" while guest curl works + +The daemon likely bound guest loopback only. This kit sets `OD_BIND_HOST=0.0.0.0` +and starts with `--host 0.0.0.0` because msb/acq create-time publishing dials the +sandbox guest network IP, not guest `127.0.0.1`. + +Confirm the bind: + +```bash +acq exec -- sh -c 'grep -i ":1D20" /proc/net/tcp' +# 1D20 hex = 7456. 00000000:1D20 is 0.0.0.0:7456. +``` + +If it is loopback-only, rebuild the sandbox with the current kit or use a +post-hoc publish path that tunnels from inside the guest. + +## 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 'cat ~/.local/share/opendesign/data/app-config.json 2>/dev/null' +``` + +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' +``` + +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..a1ea6845 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/docs/decisions/acq-owned-byok.md @@ -0,0 +1,41 @@ +# 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. + +## 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`. +- 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..6ec812dc --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/docs/decisions/disable-api-auth-loopback-boundary.md @@ -0,0 +1,36 @@ +# 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 daemon must bind `0.0.0.0` inside the guest for create-time port publishing to +work on msb-style backends. The host side of the mapping remains loopback-only. + +## Decision + +Run OpenDesign with: + +```bash +OD_DISABLE_API_AUTH=1 +OD_BIND_HOST=0.0.0.0 +OD_PORT=7456 +``` + +Document that the kit is for trusted, single-user hosts only and that the mapped +port must not be forwarded to a wider interface. + +## Consequences + +- Browser access requires no token retrieval step. +- The kit is consistent with the approved local-UI posture for ACQ browser kits. +- Anyone with access to the host loopback port 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. 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..119c20ad --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/docs/decisions/source-install-headless-daemon.md @@ -0,0 +1,51 @@ +# 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 with: + +```bash +node apps/daemon/dist/cli.js --host 0.0.0.0 --port 7456 --no-open +``` + +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. +- 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..60bfc306 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh @@ -0,0 +1,136 @@ +#!/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" +[ -n "${PROXY_CA_CERT_B64:-}" ] && printf %s "$PROXY_CA_CERT_B64" | base64 -d >>"$ca_bundle" 2>/dev/null || true +[ -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-start.sh b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh new file mode 100755 index 00000000..90b2cea2 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh @@ -0,0 +1,132 @@ +#!/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}" +OD_PORT="${OD_PORT:-7456}" +OD_BIND_HOST="${OD_BIND_HOST:-0.0.0.0}" +OD_DATA_DIR="${OD_DATA_DIR:-$APP_HOME/data}" +DAEMON_LOG="$STATE_HOME/opendesign-daemon.log" +SEED_LOG="$STATE_HOME/opendesign-seed.log" +INSTALL_SCRIPT="$HOME/opendesign-install.sh" +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 +export OD_WEB_PORT="${OD_WEB_PORT:-$OD_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 + +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; } + +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..1ba92cfe --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -0,0 +1,259 @@ +#!/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=0 +[ "$RUN_ACQ" = "1" ] && validator_required=1 +[ "${STRICT_VERIFY:-0}" = "1" ] && validator_required=1 +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); set STRICT_VERIFY=1 to require it" + 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; set STRICT_VERIFY=1 to require it" + 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 + +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")" +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 +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 "$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..." +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:7456/api/daemon/status >/dev/null 2>&1 && curl -fsS http://127.0.0.1:7456/ >/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" +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 && (!c.agentModels || Object.keys(c.agentModels).length===0) && t.metrics===false && t.content===false) ? "yes" : "no"'\'' 2>/dev/null')" +if [ "$app_config_ok" = "yes" ]; then + ok "OpenDesign app-config seeded opencode, disabled telemetry, and did not seed a model" +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 +[ "$(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" + +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 + if curl -fsS "$host_url/" >/dev/null 2>&1 && curl -fsS "$host_url/api/daemon/status" >/dev/null 2>&1; then + ok "published host-loopback URL is reachable: $host_url" + else + bad "published host-loopback URL is not reachable: $host_url" + 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..9c635504 --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/spec.yaml @@ -0,0 +1,140 @@ +# 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: `od --host 0.0.0.0 --port 7456 +# --no-open`. In-guest 0.0.0.0 is required for msb/acq create-time port +# publishing to reach the guest service; the host side remains loopback-only. +# - 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. +# - 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 + +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: + OD_BIND_HOST: "0.0.0.0" + OD_PORT: "7456" + 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. 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. + + Logs: `~/.local/state/opendesign/opendesign-daemon.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. From e568770d4ce55b3ef43215f3cb748af22fe69a23 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 04:57:34 +0000 Subject: [PATCH 02/10] fix(opendesign): make OpenCode runs reach USAi and unbreak peer-loopback-gated routes Two independent defects, both found by running the kit in a live msb sandbox: every prompt failed after ~2m43s, and Settings -> About -> Export diagnostics returned 403. 1. OpenCode never read the usai-provider config, so every run failed. 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 (upstream apps/daemon/src/sandbox-mode.ts). OpenCode resolves its global config from those variables, so it read the empty stub it had auto-created at $OD_DATA_DIR/sandbox/config/opencode/opencode.jsonc and never saw ~/.config/opencode/opencode.jsonc. With no `usai` provider defined it fell back to its own hosted gateway, which this kit's egress allowlist does not include, then exhausted its retry budget: providerID=opencode modelID=big-pickle url: https://opencode.ai/zen/v1/chat/completions AI_APICallError: Cannot connect to API The run record agreed: status=failed, failureDetail=stream_error, 163s wall time. Confirmed the mechanism by running `opencode models` under the two environments -- the sandbox env listed only opencode/* models, the host env listed usai/*. Fix: startup exports OPENCODE_CONFIG, OpenCode's documented absolute-path override, which survives the HOME/XDG rewrite. This names a config PATH only; no key material moves into OpenDesign state. USAI_API_KEY stays in the environment and is resolved by that config's own {env:USAI_API_KEY} substitution at run time, preserving docs/decisions/acq-owned-byok.md. Falls back .jsonc -> .json, honors a pre-set value, and logs which config it chose (or warns when none exists). Note there is no UI workaround for this: upstream's Settings -> Local CLI allowlist for the opencode agent accepts only OPENCODE_BIN, not a config path or a key. 2. The 0.0.0.0 bind made OpenDesign 403 its own browser requests. Upstream gates a subset of routes on the request PEER address being loopback (requireLocalDaemonRequest, apps/daemon/src/http/local-daemon-request.ts). acq/msb port publishing dials the guest network IP, not guest 127.0.0.1, so a daemon on 0.0.0.0 sees a guest-network peer for every request from the host and answers 403 on exactly three routes while the rest of the UI works: GET /api/diagnostics/export <- the Export logs button POST /api/strategies/od-next/rollout <- silently inert POST /api/diagnostics/chat-scroll-forensics The daemon's own failure journal ($OD_DATA_DIR/diagnostics/environment-evidence.json) listed precisely those three and nothing else, which rules out the export handler -- `od diagnostics export` wrote a valid bundle on the first try while the HTTP route 403'd. Fix: bind the daemon to guest 127.0.0.1 and publish it with a small supervised relay (opendesign-relay.mjs) that binds the guest network address(es) and forwards to loopback, so the daemon always observes a loopback peer. The relay is a plain byte pipe -- no parsing, no header rewriting -- so SSE run streams and websockets pass through unchanged. It skips loopback interfaces (cannot collide with the daemon), treats EADDRINUSE as benign, rescans every 10s for interfaces that appear after boot, and is supervised separately so it survives a daemon restart. Exposure is unchanged: the guest network interface was already the reachable surface, and the host side of the mapping is still loopback-only. Rejected: documenting the CLI export instead (leaves od-next broken and a UI button that always fails), and patching upstream's guard to honor OD_ALLOWED_ORIGINS (right long-term fix, but weakens a deliberate CSRF/SSRF boundary; being raised upstream instead). Also: amended docs/decisions/disable-api-auth-loopback-boundary.md in place with the measured route table and the rejected alternatives, since the PR is unmerged and its 0.0.0.0 decision was simply wrong; extended TROUBLESHOOTING.md with both symptoms, the exact log lines that identify each, and the /proc/net/tcp check that distinguishes them; added 12 offline verify assertions (bind host, relay presence/shape, OPENCODE_CONFIG export) and 6 live ones, including a regression gate that makes the exact request the Export button makes and requires HTTP 200. Verified in a live msb sandbox against open-design-v0.24.0, not mocked: - offline gate: 25 passed, 0 failed, 0 skipped (STRICT_VERIFY=1) - validate-kits.py --strict: all 8 kits valid - pytest scripts/tests/: 401 passed - make test-kits: 47 + 8 + 15 passed, 0 failed - scan_unsafe_shell.py: 0 errors, 0 warnings - validate_repo.py: all validators passed - generate_index.py --check / generate_catalog.py --check: up to date - markdownlint-cli2: 0 issues - POST /api/chat through the relay with the real USAi path: providerID=usai modelID=claude-opus-5 text_delta "RELAY-AND-CONFIG-OK", status succeeded, exit_code 0 - through the relay: /api/health 200, /api/diagnostics/export 200 (valid 10-entry zip), /api/strategies/od-next/rollout 200, chat-scroll-forensics POST 200 -- all 403 before this change - /proc/net/tcp: daemon on 0100007F (127.0.0.1) only, relay on the guest address; no 00000000 listener - grep -R USAI_API_KEY over OD_DATA_DIR: absent (rc=1) - relay edge cases: duplicate instance logs "already in use" and does not crash; bad/missing port argument exits 2 with usage; daemon kill leaves the relay up and it serves again on daemon restart - OPENCODE_CONFIG resolution exercised for all four cases (.jsonc, .json-only, none, pre-set) Rollback: `git revert` this commit. It touches only the opendesign kit plus that kit's kits.yaml parity entry; reverting restores the 0.0.0.0 bind and drops the relay, reinstating both defects but nothing else. Security impact: no new attack surface. The relay narrows what the daemon accepts (loopback peers only) rather than widening it, and adds no authentication bypass -- OD_DISABLE_API_AUTH and the host-loopback publish boundary are unchanged. The OPENCODE_CONFIG export moves a file path, not a credential. AI-assisted: investigated and authored by an AI agent; human review required before merge. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/kits.yaml | 6 +- .../isolation/acq-kits/opendesign/README.md | 56 ++++++- .../acq-kits/opendesign/TROUBLESHOOTING.md | 136 ++++++++++++++-- .../docs/decisions/acq-owned-byok.md | 20 +++ .../disable-api-auth-loopback-boundary.md | 67 +++++++- .../files/home/opendesign-relay.mjs | 149 ++++++++++++++++++ .../opendesign/files/home/opendesign-start.sh | 65 +++++++- .../acq-kits/opendesign/scripts/verify | 108 +++++++++++++ .../isolation/acq-kits/opendesign/spec.yaml | 47 +++++- 9 files changed, 624 insertions(+), 30 deletions(-) create mode 100644 integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 0c1da45a..bcadb1f1 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -94,7 +94,11 @@ kits: 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. + 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. prime-agent: backends: [sbx, msb] diff --git a/integrations/isolation/acq-kits/opendesign/README.md b/integrations/isolation/acq-kits/opendesign/README.md index 27cb1a03..a5a4478c 100644 --- a/integrations/isolation/acq-kits/opendesign/README.md +++ b/integrations/isolation/acq-kits/opendesign/README.md @@ -54,7 +54,9 @@ especially `usai-provider`. - 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 `0.0.0.0:7456` with `--no-open`. +- OpenDesign daemon startup on `127.0.0.1:7456` with `--no-open`, plus a + supervised relay that publishes that loopback listener on the guest network + address (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, @@ -62,6 +64,9 @@ especially `usai-provider`. - 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. @@ -81,6 +86,28 @@ acq secret store 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. @@ -104,17 +131,29 @@ acq ports --publish 7456:7456 ## Security -The daemon runs with `OD_DISABLE_API_AUTH=1` and binds `0.0.0.0` inside the -sandbox. This is safe only because the sandbox is the security boundary and the -host-side published port is loopback-only. +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. -The in-guest `0.0.0.0` bind is required for `acq` create-time port publishing on -msb-style backends: the host listener dials the sandbox guest network IP, not -guest `127.0.0.1`. +`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 +forwards the guest network address to the loopback daemon — rather than binding +the daemon to `0.0.0.0`. + +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 @@ -156,7 +195,8 @@ opendesign/ ├── spec.yaml # kit declaration ├── files/home/ │ ├── opendesign-install.sh # Node 24 + OpenDesign source install/build -│ └── opendesign-start.sh # app-config seed + daemon supervisor +│ ├── 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 diff --git a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md index 1547419e..f0f45d63 100644 --- a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md @@ -15,13 +15,16 @@ acq exec -- sh -c 'curl -fsS http://127.0.0.1:7456/api/daemon/status & ``` - `/api/daemon/status` returns 200 and the port is mapped: open - `http://localhost:`. + `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 @@ -124,21 +127,132 @@ acq exec -- sh -c 'tail -n 80 ~/.local/state/opendesign/opendesign-dae acq exec -- sh -c 'PATH="$HOME/.local/share/opendesign/tools/node-v24.21.0-linux-arm64/bin:$PATH" node -p "process.version + " " + process.versions.modules"' ``` -## Host curl returns "Empty reply from server" while guest curl works +## Host curl returns "Empty reply from server" / connection refused while guest curl works -The daemon likely bound guest loopback only. This kit sets `OD_BIND_HOST=0.0.0.0` -and starts with `--host 0.0.0.0` because msb/acq create-time publishing dials the -sandbox guest network IP, not guest `127.0.0.1`. +The publish relay is probably not running. The daemon binds guest `127.0.0.1` +only (on purpose — see the 403 entry below), and `~/opendesign-relay.mjs` is what +listens on the guest network address that msb/acq publishing actually dials. -Confirm the bind: +```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: ```bash acq exec -- sh -c 'grep -i ":1D20" /proc/net/tcp' -# 1D20 hex = 7456. 00000000:1D20 is 0.0.0.0:7456. +# 0100007F:1D20 state 0A -> daemon on 127.0.0.1:7456 (expected) +# :1D20 0A -> relay on the guest network (expected) +# 00000000:1D20 state 0A -> daemon on 0.0.0.0: WRONG, see the 403 entry below +``` + +If the relay log shows `no non-loopback address 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`, 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` and publishing +it through the relay. If you see a 403 anyway, check which address holds port +7456: + +```bash +acq exec -- sh -c 'grep -i ":1D20" /proc/net/tcp' +# want 0100007F:1D20 (127.0.0.1) for the daemon, NOT 00000000:1D20 (0.0.0.0) +``` + +A daemon on `0.0.0.0` means `OD_BIND_HOST` was overridden, or the sandbox was +created with an earlier kit version — recreate it with the current kit. + +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 'cat "$OD_DATA_DIR/diagnostics/environment-evidence.json"' ``` -If it is loopback-only, rebuild the sandbox with the current kit or use a -post-hoc publish path that tunnels from inside the guest. +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 'cat ~/.local/share/opendesign/data/runs/*/state.json | grep -E "status|failureDetail"' +``` + +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 'tr "\0" "\n" < /proc/$(pgrep -f "apps/daemon/dist/cli.js" | head -n1)/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 @@ -172,6 +286,10 @@ acq exec -- sh -c 'test -n "$USAI_API_KEY" && echo USAI_API_KEY-presen 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. 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 index a1ea6845..90306ed0 100644 --- a/integrations/isolation/acq-kits/opendesign/docs/decisions/acq-owned-byok.md +++ b/integrations/isolation/acq-kits/opendesign/docs/decisions/acq-owned-byok.md @@ -31,11 +31,31 @@ acq secret store -> USAI_API_KEY env -> usai-provider OpenCode config -> OpenCod 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 index 6ec812dc..39baa91e 100644 --- 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 @@ -11,8 +11,36 @@ security boundary. Adding a generated token would make first use harder and woul not protect against a local process that can already access the host loopback port and user session. -The daemon must bind `0.0.0.0` inside the guest for create-time port publishing to -work on msb-style backends. The host side of the mapping remains loopback-only. +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 @@ -20,17 +48,50 @@ Run OpenDesign with: ```bash OD_DISABLE_API_AUTH=1 -OD_BIND_HOST=0.0.0.0 +OD_BIND_HOST=127.0.0.1 OD_PORT=7456 ``` +and publish that loopback listener on the guest network address 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 unchanged relative to the `0.0.0.0` draft: the guest network + interface was already the reachable surface, and the host side of the mapping + is still loopback-only. - Anyone with access to the host loopback port 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/files/home/opendesign-relay.mjs b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs new file mode 100644 index 00000000..933e87cc --- /dev/null +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs @@ -0,0 +1,149 @@ +// 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 guest network address(es) and forwards each connection to +// 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 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, which is the kit's actual boundary. 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 net from 'node:net'; +import os from 'node:os'; + +const PORT = Number.parseInt(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(PORT) || PORT < 1 || PORT > 65535) { + console.error(`[relay] usage: opendesign-relay.mjs (got ${JSON.stringify(process.argv[2])})`); + process.exit(2); +} + +const log = (...parts) => { + console.log(`${new Date().toISOString()} [relay]`, ...parts); +}; + +/** + * Publishable addresses: every non-internal IPv4/IPv6 address on the guest. + * `internal` is Node's own flag for loopback, which the daemon already owns — + * binding it here would collide with the daemon on the same port. + */ +function publishableAddresses() { + const found = []; + for (const entries of Object.values(os.networkInterfaces())) { + 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(); + +function relayConnection(client) { + const upstream = net.connect(PORT, 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}:${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}:${PORT} (${code})`); + return; + } + log(`bind failed: ${address}:${PORT} (${code ?? err?.message})`); + permanentlyFailed.add(address); + }); + server.listen(PORT, address, () => { + bound.add(address); + log(`forwarding ${address}:${PORT} -> ${TARGET_HOST}:${PORT}`); + }); +} + +function scan() { + const addresses = publishableAddresses(); + if (addresses.length === 0 && bound.size === 0) { + log('no non-loopback address yet; will rescan'); + } + for (const address of addresses) bind(address); +} + +log(`starting for port ${PORT}; daemon expected on ${TARGET_HOST}:${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 index 90b2cea2..80065907 100755 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh @@ -14,11 +14,18 @@ SOURCE_DIR="$APP_HOME/source" NODE_VERSION="${OPENDESIGN_NODE_VERSION:-24.21.0}" PNPM_VERSION="${OPENDESIGN_PNPM_VERSION:-10.33.2}" OD_PORT="${OD_PORT:-7456}" -OD_BIND_HOST="${OD_BIND_HOST:-0.0.0.0}" +# 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. +OD_BIND_HOST="${OD_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" @@ -55,6 +62,38 @@ 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 @@ -114,6 +153,30 @@ 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 "$3" "$1" || true + echo "[supervisor] OpenDesign relay exited; restarting in ${2}s" + sleep "$2" + done + ' "supervisor:opendesign-relay" "$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 diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify index 1ba92cfe..cb9b5843 100755 --- a/integrations/isolation/acq-kits/opendesign/scripts/verify +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -90,9 +90,23 @@ for script in "$KIT_DIR/files/home/opendesign-install.sh" "$KIT_DIR/files/home/o 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 +else + skip "opendesign-relay.mjs not syntax-checked (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" ;; @@ -101,6 +115,47 @@ 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="${OD_BIND_HOST:-127.0.0.1}"'*) ok "startup defaults the daemon bind to loopback" ;; + *) bad "startup does not default the daemon bind to loopback" ;; +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 + *'entry.internal'*) ok "relay skips loopback interfaces so it cannot collide with the daemon" ;; + *) bad "relay does not skip loopback interfaces" ;; +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" ;; @@ -227,6 +282,48 @@ fi && 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" +# 1D20 hex = 7456. The daemon must hold ONLY 127.0.0.1:1D20 (0100007F), and the +# relay must hold the guest-network address. A daemon on 00000000:1D20 means the +# loopback-peer-gated routes will 403 for the host browser. +daemon_bind="$(in_acq 'awk "\$4 == \"0A\" && \$2 ~ /:1D20$/ { print \$2 }" /proc/net/tcp | sort -u | tr "\n" " "')" +case "$daemon_bind" in + *0100007F:1D20*) ok "a loopback listener holds port 7456 in-guest" ;; + *) bad "no loopback listener on port 7456 in-guest (saw: ${daemon_bind:-none})" ;; +esac +case "$daemon_bind" in + *00000000:1D20*) bad "port 7456 is bound to 0.0.0.0; peer-loopback-gated routes will 403 from the host" ;; + *) ok "port 7456 is not bound to 0.0.0.0" ;; +esac +if [ "$(in_acq 'pgrep -f "supervisor:opendesign-relay" >/dev/null 2>&1 && echo yes')" = "yes" ]; then + ok "publish relay supervisor is running in-guest" +else + bad "publish relay supervisor 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. +opencode_config="$(in_acq 'pgrep -a -f "apps/daemon/dist/cli.js" >/dev/null 2>&1 && tr "\0" "\n" < /proc/$(pgrep -f "apps/daemon/dist/cli.js" | head -n1)/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 [ "$(in_acq 'grep -q "api.gsa.usai.gov" "${OPENCODE_CONFIG:-$HOME/.config/opencode/opencode.jsonc}" 2>/dev/null && echo yes')" = "yes" ]; then + ok "the named OpenCode config points at the USAi gateway" +else + skip "named OpenCode config does not mention api.gsa.usai.gov (usai-provider may not be paired)" +fi +usai_models="$(in_acq 'OPENCODE_CONFIG="${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" timeout 60 opencode models 2>/dev/null | grep -c "^usai/"')" +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 @@ -249,6 +346,17 @@ if [ -n "$host_url" ]; then else bad "published host-loopback URL is not reachable: $host_url" fi + # Regression gate for the 0.0.0.0 bind bug: this is the exact request the + # browser's Settings -> About -> Export diagnostics button makes. It returned + # 403 FORBIDDEN ("request peer must be a loopback address") whenever the daemon + # bound 0.0.0.0, because acq/msb publishing dials the guest network IP. + export_code="$(curl -s -o /dev/null -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 (expected 200; 403 means the daemon saw a non-loopback peer)" + fi else bad "could not parse host-loopback URL from acq ports output" fi diff --git a/integrations/isolation/acq-kits/opendesign/spec.yaml b/integrations/isolation/acq-kits/opendesign/spec.yaml index 9c635504..1cc85570 100644 --- a/integrations/isolation/acq-kits/opendesign/spec.yaml +++ b/integrations/isolation/acq-kits/opendesign/spec.yaml @@ -14,12 +14,24 @@ # - 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: `od --host 0.0.0.0 --port 7456 -# --no-open`. In-guest 0.0.0.0 is required for msb/acq create-time port -# publishing to reach the guest service; the host side remains loopback-only. +# - OpenDesign runs as a headless daemon bound to guest loopback: +# `od --host 127.0.0.1 --port 7456 --no-open`. A small kit-managed relay +# (opendesign-relay.mjs) binds the guest network address 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. @@ -58,6 +70,10 @@ files: 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 @@ -94,7 +110,10 @@ volumes: size: "10G" environment: - OD_BIND_HOST: "0.0.0.0" + # 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" OD_PORT: "7456" OD_WEB_PORT: "7456" OD_DATA_DIR: "/home/agent/.local/share/opendesign/data" @@ -119,9 +138,13 @@ agentContext: | **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. 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. + `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 @@ -132,7 +155,15 @@ agentContext: | 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. - Logs: `~/.local/state/opendesign/opendesign-daemon.log` and + **Networking.** The daemon binds guest `127.0.0.1` only; a supervised relay + (`~/opendesign-relay.mjs`) forwards the published guest-network port 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 From bd2beef4f803b821ab33bc22a9fb900b7a585b58 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 05:28:41 +0000 Subject: [PATCH 03/10] fix(opendesign): force relay upstream loopback source Pin the relay's upstream socket localAddress to 127.0.0.1 so peer-loopback-gated OpenDesign routes see loopback consistently, and make live verification print guarded-route response bodies for diagnosis. Co-authored-by: OpenCode Agent --- .../files/home/opendesign-relay.mjs | 12 ++++--- .../acq-kits/opendesign/scripts/verify | 31 ++++++++++++++----- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs index 933e87cc..7d6c4759 100644 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs @@ -18,9 +18,9 @@ // // This relay resolves the conflict. The daemon binds 127.0.0.1 only. The relay // binds the guest network address(es) and forwards each connection to -// 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. +// 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) // @@ -82,7 +82,11 @@ const bound = new Set(); const permanentlyFailed = new Set(); function relayConnection(client) { - const upstream = net.connect(PORT, TARGET_HOST); + const upstream = net.connect({ + port: 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. diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify index cb9b5843..9a7a6106 100755 --- a/integrations/isolation/acq-kits/opendesign/scripts/verify +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -141,6 +141,10 @@ 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" ;; @@ -341,21 +345,34 @@ for line in sys.stdin: break ')" if [ -n "$host_url" ]; then - if curl -fsS "$host_url/" >/dev/null 2>&1 && curl -fsS "$host_url/api/daemon/status" >/dev/null 2>&1; 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 gate for the 0.0.0.0 bind bug: this is the exact request the - # browser's Settings -> About -> Export diagnostics button makes. It returned - # 403 FORBIDDEN ("request peer must be a loopback address") whenever the daemon - # bound 0.0.0.0, because acq/msb publishing dials the guest network IP. - export_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 120 \ + # 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 \ + -H "Origin: $host_url" "$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 + 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 (expected 200; 403 means the daemon saw a non-loopback peer)" + bad "diagnostics export returned HTTP ${export_code:-none} from the host" + sed 's/^/ | /' "$export_body" 2>/dev/null || true fi else bad "could not parse host-loopback URL from acq ports output" From 9f95ed276ccb2d6676805c3fd09d8a686f8cc2c4 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 06:05:32 +0000 Subject: [PATCH 04/10] fix(opendesign): correct three self-defeating live verify assertions Found by running the new live assertions against a real daemon instead of trusting them, which is what I should have done before adding them. All three are bugs in the verification I wrote in e568770, not in the kit. 1. The relay check was a guaranteed false PASS. `pgrep -f "supervisor:opendesign-relay"` matches every process whose full cmdline contains that text -- including the shell `acq exec` spawns to run the check, whose cmdline *is* the check. Demonstrated in this sandbox, which runs the unfixed kit and therefore has no relay anywhere: $ pgrep -af opendesign-relay # -> nothing $ sh -c 'pgrep -f "supervisor:opendesign-relay" && echo yes' yes # <- FALSE POSITIVE So the one assertion guarding the mechanism this PR adds could never fail. Worse than a missing check: it reports success for its own absence. Fixed with a bracketed first character (`[o]pendesign-relay\.mjs`), which cannot match the literal pattern text in the checking shell's cmdline but does match the real process. Also retargeted from the supervisor wrapper to the relay's own node process, since that is what actually holds the published port. Verified in all four states: relay absent (no), bare relay (yes), relay under its supervisor (yes), and with the plain pattern for contrast (yes when it should be no). 2. The OPENCODE_CONFIG check read the wrong process. `pgrep -f "apps/daemon/dist/cli.js" | head -n1` resolved to pid 3952 here -- the supervisor *shell*, not the daemon. The shell's cmdline contains the same path, and it sorts first: 3952 comm=sh sh -c 'while :; do node apps/daemon/dist/cli.js ...' 3954 comm=MainThread node apps/daemon/dist/cli.js --host ... --port 7456 The supervisor does inherit the exports, so the check would usually still pass -- a wrong-target read that happens to agree, which hides the mistake rather than surfacing it. Fixed by anchoring on argv[0] being node, and additionally selecting by OD_PORT rather than first-match: a host can have more than one daemon alive, and "first pid wins" silently inspects an arbitrary one. Verified both directions on a host with two daemons running -- the port-scoped lookup correctly returned empty for the unfixed daemon on 7456 and the real config path for the fixed one on 7506, where first-match returned the wrong one. 3. The app-config check failed on normal use. It asserted `agentModels` is empty. But that is runtime state OpenDesign owns, and merely opening the UI is enough for the app to persist `agentModels.opencode.model` (observed: "default", written by OpenDesign, not chosen by a user) alongside orbit, projectLocations, and privacyDecisionAt. Against this sandbox's real config the check returned "no" -- reporting a kit defect for an untouched install. The invariant it was reaching for is "the *kit* must not seed a model", which is already proven offline against the startup source ("startup does not seed a default model"). So the live check now asserts only what the kit actually writes, and reports any configured model as SKIP with its value rather than failing. Verified against both a freshly seeded config (PASS, empty) and this sandbox's UI-touched one (PASS + informational SKIP). Verified, live, against a loopback daemon + relay pair on 7506 built from this branch (including bd2beef's localAddress change): - the three corrected assertions, in their exact scripted form: bind check PASS (0100007F + guest addr, no 00000000) relay running PASS (and correctly FAILs with no relay) OPENCODE_CONFIG PASS (resolves /home/agent/.config/...) app-config PASS on fresh and on UI-touched state - through the relay: /api/health 200, /api/diagnostics/export 200, /api/strategies/od-next/rollout 200, over both IPv4 and IPv6 - POST /api/chat through the relay: text_delta "LOCALADDR-OK", status succeeded, exit_code 0 -- confirms bd2beef's localAddress: 127.0.0.1 does not break the IPv6 inbound leg - offline gate: 26 passed, 0 failed, 0 skipped (STRICT_VERIFY=1) - validate-kits.py --strict: all 8 kits valid - pytest scripts/tests/: 401 passed - make test-kits: 47 + 8 + 15 passed, 0 failed - scan_unsafe_shell.py: 0 errors, 0 warnings - validate_repo.py: all validators passed - generate_index/catalog --check: up to date - markdownlint-cli2: 0 issues - git diff --check: clean Note for anyone reproducing the offline gate: run python with -B or PYTHONDONTWRITEBYTECODE=1. validate-kits.py walks the acq-kits directory and reports a stray __pycache__ as "missing spec.yaml", so a previous unbuffered run makes the next one fail on its own artifact. Pre-existing, unrelated to this branch, and not fixed here. Rollback: `git revert` this commit. It touches only scripts/verify; the kit's runtime behavior is unchanged by it. Security impact: none. Verification-only change. No runtime file, no credential path, and no network posture is touched. AI-assisted: investigated and authored by an AI agent; human review required before merge. Co-authored-by: OpenCode Agent --- .../acq-kits/opendesign/scripts/verify | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify index 9a7a6106..25e8c5a3 100755 --- a/integrations/isolation/acq-kits/opendesign/scripts/verify +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -274,13 +274,30 @@ 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" -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 && (!c.agentModels || Object.keys(c.agentModels).length===0) && t.metrics===false && t.content===false) ? "yes" : "no"'\'' 2>/dev/null')" +# 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, disabled telemetry, and did not seed a model" + 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" \ @@ -298,10 +315,22 @@ case "$daemon_bind" in *00000000:1D20*) bad "port 7456 is bound to 0.0.0.0; peer-loopback-gated routes will 403 from the host" ;; *) ok "port 7456 is not bound to 0.0.0.0" ;; esac -if [ "$(in_acq 'pgrep -f "supervisor:opendesign-relay" >/dev/null 2>&1 && echo yes')" = "yes" ]; then - ok "publish relay supervisor is running in-guest" +# 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 supervisor is not running in-guest" + 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 @@ -310,7 +339,22 @@ fi # 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. -opencode_config="$(in_acq 'pgrep -a -f "apps/daemon/dist/cli.js" >/dev/null 2>&1 && tr "\0" "\n" < /proc/$(pgrep -f "apps/daemon/dist/cli.js" | head -n1)/environ | sed -n "s/^OPENCODE_CONFIG=//p"')" +# +# 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 the port this +# verification actually targets 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" = "7456" ] || 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 From 1fd248fc8b5e0fcf44dbad69a6745a09929489ab Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:20:28 +0000 Subject: [PATCH 05/10] fix(opendesign): harden relay and verification docs Respond to adversarial review by restricting relay peers to loopback/default gateway plus explicit overrides, requiring schema validation by default, correcting stale daemon-bind docs, and reducing sensitive troubleshooting output. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/opendesign/README.md | 18 ++++-- .../acq-kits/opendesign/TROUBLESHOOTING.md | 18 ++++-- .../disable-api-auth-loopback-boundary.md | 14 +++-- .../source-install-headless-daemon.md | 8 ++- .../files/home/opendesign-relay.mjs | 56 ++++++++++++++++++- .../acq-kits/opendesign/scripts/verify | 16 ++++-- 6 files changed, 104 insertions(+), 26 deletions(-) diff --git a/integrations/isolation/acq-kits/opendesign/README.md b/integrations/isolation/acq-kits/opendesign/README.md index a5a4478c..13b22731 100644 --- a/integrations/isolation/acq-kits/opendesign/README.md +++ b/integrations/isolation/acq-kits/opendesign/README.md @@ -143,7 +143,9 @@ OpenDesign daemon and the agents it launches. 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 forwards the guest network address to the loopback daemon — rather than binding -the daemon to `0.0.0.0`. +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 distinction matters functionally, not just cosmetically. OpenDesign gates several routes on the request **peer** address being loopback @@ -172,12 +174,14 @@ in neutral `hybrid/v1` vocabulary (`caps`, `files`, `commands`, `publishedPorts` ## Validating ```bash -# Offline gate: schema + file paths + registry. Needs python3 + jsonschema + pyyaml. -python ../validate-kits.py - -# Kit-local offline checks. +# 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 @@ -186,7 +190,9 @@ 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. +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 diff --git a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md index f0f45d63..721ee710 100644 --- a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md @@ -3,6 +3,11 @@ 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 @@ -124,7 +129,11 @@ log and effective Node path: ```bash acq exec -- sh -c 'tail -n 80 ~/.local/state/opendesign/opendesign-daemon.log' -acq exec -- sh -c 'PATH="$HOME/.local/share/opendesign/tools/node-v24.21.0-linux-arm64/bin:$PATH" node -p "process.version + " " + process.versions.modules"' +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 @@ -192,7 +201,8 @@ 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 'cat "$OD_DATA_DIR/diagnostics/environment-evidence.json"' +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: @@ -227,7 +237,7 @@ acq exec -- sh -c 'grep -E "loading path|providerID|stream error" ~/.l And check the failed run's own record: ```bash -acq exec -- sh -c 'cat ~/.local/share/opendesign/data/runs/*/state.json | grep -E "status|failureDetail"' +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 @@ -263,7 +273,7 @@ Check: ```bash acq exec -- sh -c 'command -v opencode || command -v opencode-cli || true' -acq exec -- sh -c 'cat ~/.local/share/opendesign/data/app-config.json 2>/dev/null' +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. 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 index 39baa91e..23a2628e 100644 --- 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 @@ -86,12 +86,14 @@ port must not be forwarded to a wider interface. 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 unchanged relative to the `0.0.0.0` draft: the guest network - interface was already the reachable surface, and the host side of the mapping - is still loopback-only. -- Anyone with access to the host loopback port 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. +- Exposure is narrower than the `0.0.0.0` draft: the guest network interface is + still the surface ACQ publishing reaches, but the relay accepts only loopback, + the default gateway peer, and optional `OPENDESIGN_RELAY_ALLOWED_PEERS` entries + for backend-specific forwarders. +- 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 index 119c20ad..f907a82b 100644 --- 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 @@ -23,12 +23,16 @@ carry a different Node major, so assuming the base `node` is fragile. 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 with: +the built daemon headlessly on guest loopback: ```bash -node apps/daemon/dist/cli.js --host 0.0.0.0 --port 7456 --no-open +node apps/daemon/dist/cli.js --host 127.0.0.1 --port 7456 --no-open ``` +A supervised relay publishes that loopback listener on the guest network address +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. diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs index 7d6c4759..ce062d25 100644 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs @@ -24,9 +24,11 @@ // // SECURITY POSTURE (unchanged by this file) // -// The relay does not 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, which is the kit's actual boundary. See +// 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. 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. @@ -34,6 +36,7 @@ // 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'; @@ -80,8 +83,55 @@ 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 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 defaultIpv4Gateway() { + try { + const routes = fs.readFileSync('/proc/net/route', 'utf8').trim().split('\n').slice(1); + for (const line of routes) { + const fields = line.trim().split(/\s+/); + if (fields[1] !== '00000000' || !fields[2]) continue; + const hex = fields[2].match(/../g); + if (!hex) continue; + return hex.reverse().map((part) => Number.parseInt(part, 16)).join('.'); + } + } catch { + // Non-Linux or unreadable route table: fall back to loopback-only peers. + } + return ''; +} + +function allowedPeerAddresses() { + const configured = (process.env.OPENDESIGN_RELAY_ALLOWED_PEERS ?? '') + .split(',') + .map((value) => normalizePeerAddress(value)) + .filter(Boolean); + return new Set([ + '127.0.0.1', + '::1', + '0:0:0:0:0:0:0:1', + defaultIpv4Gateway(), + ...configured, + ].filter(Boolean)); +} function relayConnection(client) { + const peer = normalizePeerAddress(client.remoteAddress); + if (!allowedPeerAddresses().has(peer)) { + if (!warnedDeniedPeers.has(peer)) { + warnedDeniedPeers.add(peer); + log(`denying non-forwarder peer ${peer || '(unknown)'}`); + } + client.destroy(); + return; + } + const upstream = net.connect({ port: PORT, host: TARGET_HOST, diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify index 25e8c5a3..70e53e9f 100755 --- a/integrations/isolation/acq-kits/opendesign/scripts/verify +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -52,14 +52,13 @@ info "0. Preconditions" info "1. Offline gates" VALIDATOR="$REPO_ROOT/integrations/isolation/acq-kits/validate-kits.py" -validator_required=0 -[ "$RUN_ACQ" = "1" ] && validator_required=1 -[ "${STRICT_VERIFY:-0}" = "1" ] && validator_required=1 +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); set STRICT_VERIFY=1 to require it" + 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 @@ -71,7 +70,7 @@ 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; set STRICT_VERIFY=1 to require it" + 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 @@ -149,6 +148,10 @@ 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 + *'OPENDESIGN_RELAY_ALLOWED_PEERS'*'defaultIpv4Gateway'*'allowedPeerAddresses'*) ok "relay restricts client peers to loopback/default gateway plus explicit overrides" ;; + *) bad "relay does not restrict client peers before forwarding" ;; +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. @@ -223,6 +226,9 @@ if ! command -v acq >/dev/null 2>&1; then 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 From 7b1b2e2df9cf27d5046162d28fc313b4aaaa58c8 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:28:29 +0000 Subject: [PATCH 06/10] test(opendesign): log relay peers on guarded-route failures Log accepted relay peers once and include relay logs when guarded host-route checks fail, so host verification can distinguish relay bypass from upstream request validation failures. Co-authored-by: OpenCode Agent --- .../acq-kits/opendesign/files/home/opendesign-relay.mjs | 5 +++++ integrations/isolation/acq-kits/opendesign/scripts/verify | 2 ++ 2 files changed, 7 insertions(+) diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs index ce062d25..13b0fda0 100644 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs @@ -83,6 +83,7 @@ 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) { @@ -131,6 +132,10 @@ function relayConnection(client) { client.destroy(); return; } + if (!warnedAcceptedPeers.has(peer)) { + warnedAcceptedPeers.add(peer); + log(`accepting peer ${peer || '(unknown)'}`); + } const upstream = net.connect({ port: PORT, diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify index 70e53e9f..721da581 100755 --- a/integrations/isolation/acq-kits/opendesign/scripts/verify +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -413,6 +413,7 @@ if [ -n "$host_url" ]; then 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" @@ -423,6 +424,7 @@ if [ -n "$host_url" ]; then 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" From e4a90a4f19f2f8d42c8fc26f77c05216f15f7c7c Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:50:46 +0000 Subject: [PATCH 07/10] fix(opendesign): force daemon loopback bind Do not inherit backend-provided OD_BIND_HOST for the OpenDesign daemon. Use a kit-specific override so ordinary service defaults like 0.0.0.0 cannot bypass the relay and break peer-loopback-gated routes. Co-authored-by: OpenCode Agent --- .../isolation/acq-kits/opendesign/TROUBLESHOOTING.md | 6 ++++-- .../docs/decisions/disable-api-auth-loopback-boundary.md | 4 ++++ .../acq-kits/opendesign/files/home/opendesign-start.sh | 5 ++++- integrations/isolation/acq-kits/opendesign/scripts/verify | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md index 721ee710..998157ee 100644 --- a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md @@ -185,8 +185,10 @@ acq exec -- sh -c 'grep -i ":1D20" /proc/net/tcp' # want 0100007F:1D20 (127.0.0.1) for the daemon, NOT 00000000:1D20 (0.0.0.0) ``` -A daemon on `0.0.0.0` means `OD_BIND_HOST` was overridden, or the sandbox was -created with an earlier kit version — recreate it with the current kit. +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: 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 index 23a2628e..81e5ea17 100644 --- 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 @@ -52,6 +52,10 @@ OD_BIND_HOST=127.0.0.1 OD_PORT=7456 ``` +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 the guest network address 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 diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh index 80065907..c649f926 100755 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh @@ -19,7 +19,10 @@ OD_PORT="${OD_PORT:-7456}" # 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. -OD_BIND_HOST="${OD_BIND_HOST:-127.0.0.1}" +# 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" diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify index 721da581..faed6726 100755 --- a/integrations/isolation/acq-kits/opendesign/scripts/verify +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -127,8 +127,8 @@ case "$spec" in *) bad "spec does not install opendesign-relay.mjs" ;; esac case "$start_src" in - *'OD_BIND_HOST="${OD_BIND_HOST:-127.0.0.1}"'*) ok "startup defaults the daemon bind to loopback" ;; - *) bad "startup does not default the daemon bind to loopback" ;; + *'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 *'supervisor:opendesign-relay'*) ok "startup supervises the publish relay" ;; From 9d698a3abbcd89b408dfe43827396eb5c7983f30 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 24 Sep 2026 03:24:35 +0000 Subject: [PATCH 08/10] fix(opendesign): align relay verification and docs Fix the live verifier readiness probe after the daemon/relay split so it checks the loopback daemon on 17456 while host verification continues to exercise published guest port 7456. Harden proxy CA handling, narrow the relay to the default-route guest interface, add a relay helper self-test, and update documentation for the source-build surface and relay trust boundary. Co-authored-by: OpenCode --- .../isolation/acq-kits/opendesign/README.md | 46 +++++--- .../acq-kits/opendesign/TROUBLESHOOTING.md | 38 ++++--- .../disable-api-auth-loopback-boundary.md | 18 +++- .../source-install-headless-daemon.md | 11 +- .../files/home/opendesign-install.sh | 10 +- .../files/home/opendesign-relay.mjs | 101 ++++++++++++------ .../opendesign/files/home/opendesign-start.sh | 18 ++-- .../acq-kits/opendesign/scripts/verify | 51 ++++++--- .../isolation/acq-kits/opendesign/spec.yaml | 17 +-- 9 files changed, 205 insertions(+), 105 deletions(-) diff --git a/integrations/isolation/acq-kits/opendesign/README.md b/integrations/isolation/acq-kits/opendesign/README.md index 13b22731..aaf7d1a0 100644 --- a/integrations/isolation/acq-kits/opendesign/README.md +++ b/integrations/isolation/acq-kits/opendesign/README.md @@ -6,8 +6,9 @@ local-first design workspace that launches existing agent CLIs. This kit uses th existing sandbox `opencode`; it does not install OpenCode and does not own model credentials. -A single OpenDesign daemon serves the API and browser UI on container port -**7456**. +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`. @@ -54,9 +55,9 @@ especially `usai-provider`. - 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 `127.0.0.1:7456` with `--no-open`, plus a - supervised relay that publishes that loopback listener on the guest network - address (see [Security](#security)). +- 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, @@ -71,6 +72,13 @@ especially `usai-provider`. 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: @@ -123,11 +131,11 @@ acq ports # OpenDesign UI: open http://localhost: ``` -Want a fixed host port instead of the ephemeral one? - -```bash -acq ports --publish 7456:7456 -``` +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 @@ -142,10 +150,15 @@ 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 -forwards the guest network address to the loopback daemon — 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. +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 @@ -161,8 +174,9 @@ in 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 also appends `PROXY_CA_CERT_B64` and the -system CA bundle to `NODE_EXTRA_CA_CERTS` when available. +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 diff --git a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md index 998157ee..478d9da7 100644 --- a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md @@ -16,7 +16,7 @@ Check the port mapping and daemon health: ```bash acq ports -acq exec -- sh -c 'curl -fsS http://127.0.0.1:7456/api/daemon/status && echo OK' +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 @@ -139,25 +139,26 @@ acq exec -- sh -c ' ## 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 purpose — see the 403 entry below), and `~/opendesign-relay.mjs` is what -listens on the guest network address that msb/acq publishing actually dials. +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: +Confirm both listeners. `1D20` hex = 7456 and `4430` hex = 17456: ```bash -acq exec -- sh -c 'grep -i ":1D20" /proc/net/tcp' -# 0100007F:1D20 state 0A -> daemon on 127.0.0.1:7456 (expected) -# :1D20 0A -> relay on the guest network (expected) -# 00000000:1D20 state 0A -> daemon on 0.0.0.0: WRONG, see the 403 entry below +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 non-loopback address yet`, the guest network came up -late; the relay rescans every 10s and should recover on its own. If it shows +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. @@ -167,8 +168,9 @@ it. 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`, requests published from the host arrive with a guest-network peer -address and these routes 403 while the rest of the UI works normally: +`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 @@ -176,13 +178,15 @@ POST /api/strategies/od-next/rollout <- OD Next strategy switch (fails s POST /api/diagnostics/chat-scroll-forensics ``` -The current kit avoids this by binding the daemon to `127.0.0.1` and publishing -it through the relay. If you see a 403 anyway, check which address holds port -7456: +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 -i ":1D20" /proc/net/tcp' -# want 0100007F:1D20 (127.0.0.1) for the daemon, NOT 00000000:1D20 (0.0.0.0) +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 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 index 81e5ea17..fcfe5139 100644 --- 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 @@ -49,14 +49,14 @@ Run OpenDesign with: ```bash OD_DISABLE_API_AUTH=1 OD_BIND_HOST=127.0.0.1 -OD_PORT=7456 +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 the guest network address with a small, +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 @@ -91,9 +91,17 @@ port must not be forwarded to a wider interface. 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 accepts only loopback, - the default gateway peer, and optional `OPENDESIGN_RELAY_ALLOWED_PEERS` entries - for backend-specific forwarders. + 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 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 index f907a82b..ecf62aa5 100644 --- 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 @@ -26,11 +26,11 @@ Install OpenDesign from the pinned upstream source tag the built daemon headlessly on guest loopback: ```bash -node apps/daemon/dist/cli.js --host 127.0.0.1 --port 7456 --no-open +node apps/daemon/dist/cli.js --host 127.0.0.1 --port 17456 --no-open ``` -A supervised relay publishes that loopback listener on the guest network address -that ACQ/MSB create-time port publishing dials; see +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 @@ -42,6 +42,11 @@ fail-soft: failures are logged but do not fail sandbox creation/startup. - 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 diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh index 60bfc306..0418642f 100755 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh @@ -94,7 +94,15 @@ node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) [ -n "${HTTP_PROXY:-${http_proxy:-}}" ] && export npm_config_proxy="${HTTP_PROXY:-$http_proxy}" ca_bundle="$STATE_HOME/ca-bundle.pem" : >"$ca_bundle" -[ -n "${PROXY_CA_CERT_B64:-}" ] && printf %s "$PROXY_CA_CERT_B64" | base64 -d >>"$ca_bundle" 2>/dev/null || true +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" diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs index 13b0fda0..de7eab67 100644 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs @@ -17,8 +17,8 @@ // to connect to. // // This relay resolves the conflict. The daemon binds 127.0.0.1 only. The relay -// binds the guest network address(es) and forwards each connection to -// 127.0.0.1 from localAddress 127.0.0.1, so the daemon always observes a +// 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. // @@ -28,7 +28,9 @@ // 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. See +// 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. @@ -40,7 +42,9 @@ import fs from 'node:fs'; import net from 'node:net'; import os from 'node:os'; -const PORT = Number.parseInt(process.argv[2] ?? '', 10); +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. @@ -49,8 +53,9 @@ const RESCAN_INTERVAL_MS = Number.parseInt( 10, ); -if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) { - console.error(`[relay] usage: opendesign-relay.mjs (got ${JSON.stringify(process.argv[2])})`); +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); } @@ -58,14 +63,40 @@ const log = (...parts) => { console.log(`${new Date().toISOString()} [relay]`, ...parts); }; +function parseDefaultIpv4Route(routeTable) { + for (const line of routeTable.trim().split('\n').slice(1)) { + const fields = line.trim().split(/\s+/); + if (fields[1] !== '00000000' || !fields[2]) continue; + const hex = fields[2].match(/../g); + if (!hex) continue; + return { + iface: fields[0], + gateway: hex.reverse().map((part) => Number.parseInt(part, 16)).join('.'), + }; + } + return { iface: '', 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: every non-internal IPv4/IPv6 address on the guest. - * `internal` is Node's own flag for loopback, which the daemon already owns — - * binding it here would collide with the daemon on the same port. + * 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() { + const route = defaultIpv4Route(); + if (!route.iface) return []; const found = []; - for (const entries of Object.values(os.networkInterfaces())) { + for (const [name, entries] of Object.entries(os.networkInterfaces())) { + if (name !== route.iface) continue; for (const entry of entries ?? []) { if (!entry || entry.internal) continue; if (entry.family !== 'IPv4' && entry.family !== 'IPv6') continue; @@ -92,22 +123,6 @@ function normalizePeerAddress(address) { return normalized.startsWith('::ffff:') ? normalized.slice('::ffff:'.length) : normalized; } -function defaultIpv4Gateway() { - try { - const routes = fs.readFileSync('/proc/net/route', 'utf8').trim().split('\n').slice(1); - for (const line of routes) { - const fields = line.trim().split(/\s+/); - if (fields[1] !== '00000000' || !fields[2]) continue; - const hex = fields[2].match(/../g); - if (!hex) continue; - return hex.reverse().map((part) => Number.parseInt(part, 16)).join('.'); - } - } catch { - // Non-Linux or unreadable route table: fall back to loopback-only peers. - } - return ''; -} - function allowedPeerAddresses() { const configured = (process.env.OPENDESIGN_RELAY_ALLOWED_PEERS ?? '') .split(',') @@ -117,11 +132,27 @@ function allowedPeerAddresses() { '127.0.0.1', '::1', '0:0:0:0:0:0:0:1', - defaultIpv4Gateway(), + defaultIpv4Route().gateway, ...configured, ].filter(Boolean)); } +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`); + } +} + +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\neth0\t00000000\t0141A8C0\t0003\n'); + assertEqual(route.iface, 'eth0', 'default route interface'); + assertEqual(route.gateway, '192.168.65.1', 'default route gateway'); + console.log('relay self-test passed'); + process.exit(0); +} + function relayConnection(client) { const peer = normalizePeerAddress(client.remoteAddress); if (!allowedPeerAddresses().has(peer)) { @@ -138,7 +169,7 @@ function relayConnection(client) { } const upstream = net.connect({ - port: PORT, + port: TARGET_PORT, host: TARGET_HOST, localAddress: TARGET_HOST, }); @@ -164,34 +195,34 @@ function bind(address) { // 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}:${PORT}`); + 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}:${PORT} (${code})`); + log(`address unavailable (will retry): ${address}:${LISTEN_PORT} (${code})`); return; } - log(`bind failed: ${address}:${PORT} (${code ?? err?.message})`); + log(`bind failed: ${address}:${LISTEN_PORT} (${code ?? err?.message})`); permanentlyFailed.add(address); }); - server.listen(PORT, address, () => { + server.listen(LISTEN_PORT, address, () => { bound.add(address); - log(`forwarding ${address}:${PORT} -> ${TARGET_HOST}:${PORT}`); + log(`forwarding ${address}:${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}`); }); } function scan() { const addresses = publishableAddresses(); if (addresses.length === 0 && bound.size === 0) { - log('no non-loopback address yet; will rescan'); + log('no default-route interface address yet; will rescan'); } for (const address of addresses) bind(address); } -log(`starting for port ${PORT}; daemon expected on ${TARGET_HOST}:${PORT}`); +log(`starting for published port ${LISTEN_PORT}; daemon expected on ${TARGET_HOST}:${TARGET_PORT}`); scan(); const rescan = setInterval(scan, RESCAN_INTERVAL_MS); rescan.unref?.(); diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh index c649f926..bd7a2904 100755 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh @@ -13,7 +13,11 @@ 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}" -OD_PORT="${OD_PORT:-7456}" +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, @@ -53,8 +57,8 @@ 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 -export OD_WEB_PORT="${OD_WEB_PORT:-$OD_PORT}" +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}" @@ -171,11 +175,11 @@ if [ "$OD_BIND_HOST" = "127.0.0.1" ] || [ "$OD_BIND_HOST" = "localhost" ]; then sh -c ' while :; do echo "[supervisor] starting OpenDesign relay at $(date -u +%FT%TZ)" - node "$3" "$1" || true - echo "[supervisor] OpenDesign relay exited; restarting in ${2}s" - sleep "$2" + node "$4" "$1" "$2" || true + echo "[supervisor] OpenDesign relay exited; restarting in ${3}s" + sleep "$3" done - ' "supervisor:opendesign-relay" "$OD_PORT" "$RESTART_DELAY" "$RELAY_SCRIPT" + ' "supervisor:opendesign-relay" "$OPENDESIGN_PUBLISHED_PORT" "$OD_PORT" "$RESTART_DELAY" "$RELAY_SCRIPT" ) >>"$RELAY_LOG" 2>&1 & fi fi diff --git a/integrations/isolation/acq-kits/opendesign/scripts/verify b/integrations/isolation/acq-kits/opendesign/scripts/verify index faed6726..72b7b197 100755 --- a/integrations/isolation/acq-kits/opendesign/scripts/verify +++ b/integrations/isolation/acq-kits/opendesign/scripts/verify @@ -98,8 +98,14 @@ elif command -v node >/dev/null 2>&1; then else bad "opendesign-relay.mjs has syntax errors" fi + if node "$RELAY" --self-test >/dev/null 2>&1; then + ok "opendesign-relay.mjs peer and route helpers pass self-test" + else + bad "opendesign-relay.mjs peer and route helpers fail self-test" + 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")" @@ -130,6 +136,10 @@ 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" ;; @@ -149,7 +159,11 @@ case "$relay_src" in *) bad "relay does not skip loopback interfaces" ;; esac case "$relay_src" in - *'OPENDESIGN_RELAY_ALLOWED_PEERS'*'defaultIpv4Gateway'*'allowedPeerAddresses'*) ok "relay restricts client peers to loopback/default gateway plus explicit overrides" ;; + *'defaultIpv4Route'*'Object.entries(os.networkInterfaces())'*'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'*'defaultIpv4Route().gateway'*'allowedPeerAddresses'*) ok "relay restricts client peers to loopback/default gateway plus explicit overrides" ;; *) bad "relay does not restrict client peers before forwarding" ;; esac # OpenDesign rewrites HOME/XDG_CONFIG_HOME for agent children (OD_SANDBOX_MODE), @@ -187,6 +201,10 @@ 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" ;; @@ -245,7 +263,7 @@ startup_checked=0 health="" while :; do now="$(date +%s)" - if [ "$(in_acq 'curl -fsS http://127.0.0.1:7456/api/daemon/status >/dev/null 2>&1 && curl -fsS http://127.0.0.1:7456/ >/dev/null 2>&1 && echo yes')" = "yes" ]; then + 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 @@ -309,17 +327,22 @@ fi && 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" -# 1D20 hex = 7456. The daemon must hold ONLY 127.0.0.1:1D20 (0100007F), and the -# relay must hold the guest-network address. A daemon on 00000000:1D20 means the -# loopback-peer-gated routes will 403 for the host browser. -daemon_bind="$(in_acq 'awk "\$4 == \"0A\" && \$2 ~ /:1D20$/ { print \$2 }" /proc/net/tcp | sort -u | tr "\n" " "')" +# 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:1D20*) ok "a loopback listener holds port 7456 in-guest" ;; - *) bad "no loopback listener on port 7456 in-guest (saw: ${daemon_bind:-none})" ;; + *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" in - *00000000:1D20*) bad "port 7456 is bound to 0.0.0.0; peer-loopback-gated routes will 403 from the host" ;; - *) ok "port 7456 is not bound to 0.0.0.0" ;; +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. @@ -357,9 +380,9 @@ fi # 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 the port this -# verification actually targets 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" = "7456" ] || continue; echo "$p"; break; done' +# 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" diff --git a/integrations/isolation/acq-kits/opendesign/spec.yaml b/integrations/isolation/acq-kits/opendesign/spec.yaml index 1cc85570..d77f42b1 100644 --- a/integrations/isolation/acq-kits/opendesign/spec.yaml +++ b/integrations/isolation/acq-kits/opendesign/spec.yaml @@ -14,10 +14,11 @@ # - 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: -# `od --host 127.0.0.1 --port 7456 --no-open`. A small kit-managed relay -# (opendesign-relay.mjs) binds the guest network address and forwards to that -# loopback listener, because msb/acq create-time port publishing dials the +# - 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 @@ -114,7 +115,9 @@ environment: # 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" - OD_PORT: "7456" + 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" @@ -155,8 +158,8 @@ agentContext: | 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` only; a supervised relay - (`~/opendesign-relay.mjs`) forwards the published guest-network port to it. + **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 From 921dc2c744d0cf1c5d60ce848aceeb51fde04d68 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 24 Sep 2026 05:24:55 +0000 Subject: [PATCH 09/10] docs(acq-kits): clean pi kit merge references Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/kits.yaml | 4 ++-- .../isolation/acq-kits/pi-coding-agent/README.md | 11 +++++------ .../decisions/ca-bundle-wrapper-not-env-var.md | 6 +++--- .../docs/decisions/local-prefix-not-npm-global.md | 6 +++--- .../isolation/acq-kits/pi-coding-agent/spec.yaml | 14 +++++++------- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 18ab3b67..8554ceee 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -113,8 +113,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/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 From 58247263b73879beb790b3e1750c299461902ae2 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 14 Oct 2026 03:10:43 +0000 Subject: [PATCH 10/10] fix(acq-kits): prefer reachable OpenDesign route Prefer active gateway-bearing routes over point-to-point defaults so the relay keeps authorizing the ACQ forwarder. Document daemon-specific OpenCode config inspection. Co-authored-by: OpenCode Agent --- .../acq-kits/opendesign/TROUBLESHOOTING.md | 10 +++++++++- .../opendesign/files/home/opendesign-relay.mjs | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md index 478d9da7..8600daae 100644 --- a/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md @@ -251,7 +251,15 @@ daemon, and that OpenCode resolves `usai/*` models under the same rewritten environment OpenDesign uses: ```bash -acq exec -- sh -c 'tr "\0" "\n" < /proc/$(pgrep -f "apps/daemon/dist/cli.js" | head -n1)/environ | grep OPENCODE_CONFIG' +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" \ diff --git a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs index ff45f1c9..41e9882b 100644 --- a/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs +++ b/integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs @@ -70,15 +70,20 @@ function parseDefaultIpv4Route(routeTable) { 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 >= selected.metric) continue; + || !Number.isFinite(metric) || metric < 0 || !/^[0-9a-fA-F]{8}$/.test(fields[2])) continue; const hex = fields[2].match(/../g); - if (!hex) continue; 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: gateway === '0.0.0.0' ? '' : gateway, + gateway: hasGateway ? gateway : '', metric, }; } @@ -172,6 +177,11 @@ if (SELF_TEST) { 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');