From 60a8486895597d9f31065502450e0ba50e722973 Mon Sep 17 00:00:00 2001 From: Mark Aronson Date: Mon, 14 Sep 2026 18:31:53 +0000 Subject: [PATCH] feat(kits): add goose-server chat-harness acq mixin kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the first acq chat-harness kit: run the goose ACP server (goose serve, "goosed") inside the sandbox and publish its port so a native goose Desktop app on the host drives it as an external server (goose's documented remote-server pattern). All agent work — the interactive loop, tool execution, MCP extensions, file/shell access, LLM egress — runs under acq's isolation; Desktop is a thin UI. - Neutral hybrid/v1 mixin: create-time install of a pinned, sha256-verified, arch-selected goose CLI; startup supervises `goose serve` on 0.0.0.0:3284, published to a host-loopback port (0.0.0.0 in-guest so msb create-time publish reaches it). - Provider: ships a goose custom provider (custom_usai) matching what a GSA goose Desktop uses; key injected as CUSTOM_USAI_API_KEY (never stored in the kit). The provider's model list is GENERATED from the single USAi catalog by a new emitter (integrations/providers/usai/emitters/goose.mjs) with a byte-exact lockstep test, so it stays in sync with OpenCode/prime-agent instead of drifting as a hand-maintained snapshot. Default model: claude-sonnet-5. - Auth: shared secret-key handshake (GOOSE_SERVER__SECRET_KEY) — goose Desktop 1.50.0's ACP client always sends a secret, so unauthenticated is rejected. - Docs: README, TROUBLESHOOTING, ADRs (split, transport, provider, and an alignment note recording expected migration to devenv process supervision + declarative install + kit-inference once those directions land). - Verified live end-to-end on msb (install, server, config merge, provider stage, published port, 0.0.0.0 bind, secret-key handshake, self-heal) and connected a real goose Desktop with a working USAi model turn. Registers the kit in kits.yaml and the kits README. Co-authored-by: OpenCode Agent --- integrations/isolation/acq-kits/README.md | 1 + .../isolation/acq-kits/goose-server/README.md | 267 ++++++++++ .../acq-kits/goose-server/TROUBLESHOOTING.md | 157 ++++++ .../0001-goosed-in-sandbox-desktop-on-host.md | 80 +++ .../decisions/0002-plaintext-http-loopback.md | 82 +++ .../0003-usai-openai-compatible-provider.md | 90 ++++ ...alignment-with-devenv-harness-direction.md | 87 ++++ .../files/home/goose-config/config.yaml | 56 ++ .../custom_providers/custom_usai.json | 160 ++++++ .../files/home/goose-config/merge-config.mjs | 231 +++++++++ .../goose-server/files/home/goose-install.sh | 228 +++++++++ .../goose-server/files/home/goose-start.sh | 162 ++++++ .../acq-kits/goose-server/scripts/verify | 481 ++++++++++++++++++ .../isolation/acq-kits/goose-server/spec.yaml | 310 +++++++++++ integrations/isolation/acq-kits/kits.yaml | 36 ++ integrations/providers/usai/README.md | 20 +- .../providers/usai/emitters/goose.mjs | 155 ++++++ .../usai/tests/goose-provider-regen.test.mjs | 102 ++++ .../providers/usai/tests/goose.test.mjs | 175 +++++++ 19 files changed, 2874 insertions(+), 6 deletions(-) create mode 100644 integrations/isolation/acq-kits/goose-server/README.md create mode 100644 integrations/isolation/acq-kits/goose-server/TROUBLESHOOTING.md create mode 100644 integrations/isolation/acq-kits/goose-server/docs/decisions/0001-goosed-in-sandbox-desktop-on-host.md create mode 100644 integrations/isolation/acq-kits/goose-server/docs/decisions/0002-plaintext-http-loopback.md create mode 100644 integrations/isolation/acq-kits/goose-server/docs/decisions/0003-usai-openai-compatible-provider.md create mode 100644 integrations/isolation/acq-kits/goose-server/docs/decisions/0004-alignment-with-devenv-harness-direction.md create mode 100644 integrations/isolation/acq-kits/goose-server/files/home/goose-config/config.yaml create mode 100644 integrations/isolation/acq-kits/goose-server/files/home/goose-config/custom_providers/custom_usai.json create mode 100644 integrations/isolation/acq-kits/goose-server/files/home/goose-config/merge-config.mjs create mode 100755 integrations/isolation/acq-kits/goose-server/files/home/goose-install.sh create mode 100755 integrations/isolation/acq-kits/goose-server/files/home/goose-start.sh create mode 100644 integrations/isolation/acq-kits/goose-server/scripts/verify create mode 100644 integrations/isolation/acq-kits/goose-server/spec.yaml create mode 100644 integrations/providers/usai/emitters/goose.mjs create mode 100644 integrations/providers/usai/tests/goose-provider-regen.test.mjs create mode 100644 integrations/providers/usai/tests/goose.test.mjs diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index 6dd74ee6..05de1645 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. | +| [`goose-server/`](goose-server/) | Run the goose ACP server (`goose serve`, "goosed") inside the sandbox and publish its port so a NATIVE goose Desktop app on the host drives it as an external server. Installs a pinned goose CLI, ships the USAi custom provider, uses a secret-key handshake over a host-loopback port. Opt-in chat-harness mixin. | 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/goose-server/README.md b/integrations/isolation/acq-kits/goose-server/README.md new file mode 100644 index 00000000..479772ad --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/README.md @@ -0,0 +1,267 @@ +# goose-server (acq mixin kit, `hybrid/v1`) + +Runs the [goose](https://github.com/aaif-goose/goose) ACP server (`goose serve`, +"goosed") **inside an `acq` sandbox** and publishes its port to the host, so a +[goose Desktop](https://goose-docs.ai/docs/getting-started/installation) app +running **natively on your host machine** can drive it as an **external server**. + +This is the first `acq` **chat-harness** kit: it extends the sandbox platform +beyond coding-agent CLIs to a desktop chat client, without virtualizing a GUI +inside the sandbox. + +## The architecture (and why it is split this way) + +goose has three parts (see the +[goose architecture guide](https://goose-docs.ai/docs/goose-architecture/)): + +- **Interface** — the desktop app or CLI (collects input, renders output). +- **Agent / server** — `goose serve`, the "goosed" ACP server. Runs the + interactive loop, executes tools, connects MCP extensions, touches the + filesystem. +- **Extensions** — MCP servers the agent calls. + +This kit puts the **server inside the sandbox** and leaves the **interface on the +host**: + +``` + host machine acq sandbox (ephemeral, isolated) + ┌────────────────┐ http/loopback ┌───────────────────────────────────┐ + │ goose Desktop │ ─────────────────▶ │ goose serve (goosed, port 3284) │ + │ (native UI) │ published port │ ├─ interactive loop │ + └────────────────┘ │ ├─ tool execution (files, shell) │ + │ ├─ MCP extensions │ + │ └─ LLM calls → USAi gateway │ + └───────────────────────────────────┘ +``` + +Every action with real blast radius — file edits, shell commands, network egress, +credential use — happens **in the sandbox**, where all of acq's controls apply: +ephemeral container/microVM, deny-default allow-listed egress, no host filesystem, +credentials injected on the wire. The desktop app is a thin renderer pointed at +the server. This is goose's own documented +[remote-server pattern](https://goose-docs.ai/docs/guides/remote-goose-server) +(goose Desktop → Settings → goose Server → external server), not a workaround. + +This kit is **opt-in** (not one of the default GSA kits); you add it explicitly. + +## Quick start + +1. **Store the USAi API key once** as a custom acq secret bound to the USAi host, + injected into the guest as `CUSTOM_USAI_API_KEY` (the env var the shipped + `custom_usai` provider reads). The key value never enters the guest or the kit — + acq swaps it on the wire only for `api.gsa.usai.gov`: + + ```bash + acq secret set -g goose-usai --host api.gsa.usai.gov --env CUSTOM_USAI_API_KEY + ``` + +2. **Set the kit reference** (a full 40-char commit SHA of this repo — branches + and tags are rejected): + + ```bash + export ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=&dir=integrations/isolation/acq-kits/goose-server" + ``` + +3. **Start the sandbox.** The goosed server is brought up entirely by the kit's + startup command and held open by the sandbox's keepalive, so a detached + `acq create` is enough — you do not need to keep a terminal attached: + + ```bash + acq create --name goose-dev shell /path/to/your/project + ``` + + On first boot the goose CLI is already present (installed at create time), and + the server comes up within a few seconds. + +4. **Find the host port and point goose Desktop at it.** + + ```bash + acq ports goose-dev # look up the host port mapped to container 3284 + ``` + + In goose Desktop → **Settings → goose Server**: + + | Setting | Value | + |---|---| + | **Use external server** | Enabled | + | **URL** | `http://127.0.0.1:` | + | **Secret Key** | `goose-server-kit-local-loopback` (the kit's `GOOSE_SERVER__SECRET_KEY`) | + | **Certificate Fingerprint** | *(leave empty — HTTP, no TLS in v1)* | + + Save, then **fully Quit goose Desktop (Cmd-Q) and reopen it** — the external + server setting is only read at startup. goose Desktop now routes all backend + work to the in-sandbox server. Pick the **USAi** provider (`custom_usai`) and a + model such as `claude-sonnet-5` in Desktop's model selector. + +That's the whole flow. The server starts automatically; you never run a separate +command to bring it up. + +> **Provider must match.** goose Desktop drives the external server by *named +> provider*. This kit defines a custom provider called **`custom_usai`** on the +> server (see [Provider](#provider-usai)); your Desktop must have a `custom_usai` +> provider selected (most GSA goose Desktops already do). If Desktop asks for a +> provider the server hasn't defined, the session fails to initialize and Desktop +> reports "unable to connect" — even though the server itself is healthy. + +## Keep it running + +Unlike the paseo/openchamber kits, this kit does **not** ship an entrypoint +wrapper, because the goosed server is a background service held open by the +sandbox's keepalive (PID 1) — not something you interact with in the terminal. + +A detached `acq create` is therefore sufficient to keep the server alive: the +startup command supervises `goose serve` for the container's lifetime regardless +of whether anyone attaches. (A session-less sandbox may be auto-stopped by acq +after a long idle; if that happens, `acq start ` or a fresh `acq create` +brings it back.) + +## Provider (USAi) + +The kit ships a goose **custom provider** named `custom_usai` (an +OpenAI-compatible custom provider — `files/home/goose-config/custom_providers/ +custom_usai.json`) pointed at the GSA **USAi** gateway. It is copied into the +guest's `~/.config/goose/custom_providers/` at startup and marked active in the +config primer: + +- provider name: `custom_usai` (engine `openai`) +- `base_url`: `https://api.gsa.usai.gov/api/v1/chat/completions` +- `api_key_env`: `CUSTOM_USAI_API_KEY` +- default model: `claude-sonnet-5` (change it in Desktop or via `GOOSE_MODEL`) + +**Model list is generated, not hand-maintained.** `custom_usai.json` is emitted +from the single USAi catalog (`integrations/providers/usai/catalog.json`) by +`integrations/providers/usai/emitters/goose.mjs`, exactly like the OpenCode and +prime-agent provider configs. A byte-exact lockstep test +(`integrations/providers/usai/tests/goose-provider-regen.test.mjs`) fails CI if the +committed file drifts from the catalog. To change the models goose Desktop offers, +update `catalog.json` and regenerate — do **not** hand-edit `custom_usai.json`: + +```bash +cd integrations/providers/usai +node -e 'import("./emitters/goose.mjs").then(async (m)=>{const fs=await import("node:fs");fs.writeFileSync("../../isolation/acq-kits/goose-server/files/home/goose-config/custom_providers/custom_usai.json", m.emitGooseProviderFromCatalog(JSON.parse(fs.readFileSync("./catalog.json","utf8"))))})' +npm test # runs the lockstep guard +``` + +**Why a named custom provider, not the built-in `openai` provider:** goose Desktop +drives the external server by *named provider + model*. A GSA goose Desktop is +configured with the `custom_usai` provider, so the server must define that same +provider or the session can't initialize (Desktop shows "unable to connect"). The +kit reproduces the exact `custom_usai` definition so the two agree. + +The **API key** is the only secret. It is injected at runtime by acq as +`CUSTOM_USAI_API_KEY` (the `goose-usai` custom secret you set in step 1, matching +the provider's `api_key_env`), bound to `api.gsa.usai.gov`, and never stored in the +kit or written into the guest in cleartext. + +For model selection and custom-provider details see the goose +[provider guide](https://goose-docs.ai/docs/getting-started/providers). + +## Security note + +The server runs over plain HTTP on a **host-loopback** published port, with a +**shared secret-key handshake** (`GOOSE_SERVER__SECRET_KEY`). goose Desktop 1.50.0's +ACP client always sends a secret (`X-Secret-Key` on `/status`, `?token=` on `/acp`), +so an unauthenticated server is rejected by Desktop with "server cannot connect" +even though `/status` is reachable — hence the kit sets a secret and you paste the +same value into Desktop. + +That secret is a **cleartext loopback handshake token**, not a protected credential: +it lives in the kit's non-secret env, guards only a host-loopback port, and must +also be entered into Desktop's settings. **The sandbox is the real security +boundary** (ephemeral, deny-default allow-listed egress, no host FS, injected +credentials) and the host side of the publish is loopback only. The kit's default +value is `goose-server-kit-local-loopback`; override it per sandbox with +`-e GOOSE_SERVER__SECRET_KEY=` if you prefer a unique token (and set the same +value in Desktop). + +**TLS + certificate-fingerprint pinning** (goose's stronger remote-server posture) +is a tracked follow-up. When adopted, the server would run `--tls`, log a +`GOOSED_CERT_FINGERPRINT`, and Desktop would pin it; see +docs/decisions/0002-plaintext-http-loopback.md. + +## Backend parity + +Written entirely in the neutral `hybrid/v1` vocabulary with no backend shortcut or +extras. Supported backends: **sbx, msb**. + +- The single published container port (**3284**, goosed's ACP API + WebSocket) is + declared with the neutral top-level `publishedPorts` list. acq's kit translation + maps it to the active backend's native port-publish primitive, so the port is + published at create time on both backends with no manual step. +- The never-exiting startup supervisor is marked with the neutral + `background: true` flag, which acq maps to each backend's native detached hook. +- The goose CLI is installed by an **install-phase** command (create-time, root): + a single pinned, sha256-verified static binary selected by guest architecture + (`x86_64` / `aarch64` — msb microVMs are aarch64 on Apple Silicon), so both + backends install the same verified artifact. +- The in-guest bind is **0.0.0.0:3284** (not loopback) — required for acq's + create-time port publish to reach the guest on msb, where `-p HOST:GUEST` dials + the guest network IP rather than guest loopback. The host side stays + loopback-only, so 0.0.0.0 in-guest does not widen host exposure. (Same lesson as + the paseo/openchamber kits and quickstart `KNOWN_FAILURE_MODES.md`.) + +No backend shortcut is needed: every backend installs the same binary, allow-lists +the same hosts, runs the same supervise/merge startup script, ships the same +`custom_usai` provider definition, and reads the same injected +`CUSTOM_USAI_API_KEY`. + +## What it installs / drops + +| Path in guest | Purpose | +|---|---| +| `/usr/local/bin/goose` | The pinned goose CLI (installed at create time). | +| `/home/agent/goose-install.sh` | Create-time installer (sha256-verified). | +| `/home/agent/goose-start.sh` | Startup: merge config, stage provider, supervise `goose serve`. | +| `/home/agent/goose-config/config.yaml` | Staged USAi config primer. | +| `/home/agent/goose-config/merge-config.mjs` | Merges the primer into `~/.config/goose/config.yaml` (fail-safe). | +| `/home/agent/goose-config/custom_providers/custom_usai.json` | Staged `custom_usai` provider definition. | +| `~/.config/goose/config.yaml` | goose global config (written/merged at startup). | +| `~/.config/goose/custom_providers/custom_usai.json` | The `custom_usai` provider on the server (copied at startup). | +| `~/.local/state/goose/goose-serve.log` | Server log (respawn supervisor). | +| `~/.local/state/goose/goose-config-merge.log` | Config-merge + provider-stage log. | + +## Verify + +```bash +# Offline gate (schema + registry + merge-helper checks): +integrations/isolation/acq-kits/goose-server/scripts/verify + +# Live end-to-end (needs a sandbox-capable host + the acq CLI): +RUN_ACQ=1 integrations/isolation/acq-kits/goose-server/scripts/verify +``` + +The live check creates a real sandbox with the kit, then asserts the CLI install, +the supervised server, the config merge, the published port, the in-guest 0.0.0.0 +bind, host reachability, the secret-key handshake, and supervisor self-heal. + +## Roadmap + +- **Increment B (tracked separately):** make `acq run goose ` work without + riding on `shell`. Under the team's in-flight direction this should be `acq` + *inferring* the `goose-server` kit from the command, **not** a new per-agent token + in `acq` — see the alignment note below and + `docs/decisions/0004-alignment-with-devenv-harness-direction.md`. +- **TLS + fingerprint pinning:** adopt goose's stronger remote-server transport. + +## Alignment with the devenv / harness-adapter direction + +This kit was built against the current stack (hybrid/v1 kits, backend base images, +an imperative installer, and a shell respawn loop). The team is discussing a move +to a generic **devenv** base image with all agent knowhow in kits, a standardized +devenv process-supervision method, and `acq` inferring kits from the invoked +command. Three parts of this kit are expected to migrate when those decisions land: +its respawn supervisor → a devenv `process`; its imperative installer → a +declarative devenv package; and Increment B → kit inference (not a token). The kit +also stresses the "harness adapter" abstraction, since its client is a native goose +Desktop app on the host rather than an agent `acq` drives directly. Full rationale +and the tracked migrations are in +`docs/decisions/0004-alignment-with-devenv-harness-direction.md`. + +See the `docs/decisions/` records for the design rationale. + +## Related + +- [goose architecture](https://goose-docs.ai/docs/goose-architecture/) +- [Running a remote goose server](https://goose-docs.ai/docs/guides/remote-goose-server) +- Sibling kits: [`openchamber/`](../openchamber/), [`paseo/`](../paseo/) — other + in-sandbox servers published to the host. diff --git a/integrations/isolation/acq-kits/goose-server/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/goose-server/TROUBLESHOOTING.md new file mode 100644 index 00000000..f5b49241 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/TROUBLESHOOTING.md @@ -0,0 +1,157 @@ +# Troubleshooting — goose-server kit + +## goose Desktop can't connect / "connection refused" or times out + +1. **Confirm the server is up in the guest** (send the kit secret, as Desktop does): + + ```bash + acq exec -- sh -c 's="$GOOSE_SERVER__SECRET_KEY"; curl -fsS -H "X-Secret-Key: $s" http://127.0.0.1:3284/status && echo OK' + ``` + + - `OK` → the server is running; the problem is the host↔guest publish (see 2) or + the Desktop secret/URL (see 4). + - No output → the server is not up; see "Server did not start" below. + +2. **Confirm the host port and use it exactly.** + + ```bash + acq ports # find the host port mapped to container 3284 + ``` + + In goose Desktop → Settings → goose Server, the **URL** must be + `http://127.0.0.1:` (HTTP, not HTTPS, in v1), **Certificate + Fingerprint** empty. + +3. **"Empty reply from server" on the host port** (browser/Desktop) while the + in-guest curl in step 1 works. This is the classic msb create-time-publish + symptom when the server binds guest loopback instead of `0.0.0.0`. The kit sets + `GOOSE_SERVE_HOST=0.0.0.0`; confirm it reached the guest: + + ```bash + acq exec -- sh -c 'echo "$GOOSE_SERVE_HOST"' # expect 0.0.0.0 + acq exec -- sh -c 'ss -ltnH 2>/dev/null | grep 3284 || awk "\$2 ~ /:0CD4\$/ && \$4==\"0A\"" /proc/net/tcp' + ``` + + A LISTEN address of `0.0.0.0:3284` (or hex `00000000:0CD4`) is correct; + `127.0.0.1:3284` (`0100007F:0CD4`) is the bug — see + `GSA-TTS/agentic-coding-quickstart#333` and that repo's `KNOWN_FAILURE_MODES.md`. + +4. **"server cannot connect" while the host `curl` in step 1 succeeds, and no server + log activity on Desktop launch.** goose Desktop 1.50.0's ACP client ALWAYS sends + a secret-key handshake, so the **Secret Key** field in Desktop must match the + server's `GOOSE_SERVER__SECRET_KEY` (kit default `goose-server-kit-local-loopback`). + An empty or mismatched Secret Key fails to connect even though the server is + healthy. Confirm the values match: + + ```bash + # server's secret (what Desktop must send): + acq exec -- sh -c 'echo "$GOOSE_SERVER__SECRET_KEY"' + # prove the handshake end-to-end from the host with that value: + acq ports # get the host port + curl -i -H "X-Secret-Key: " http://127.0.0.1:/status # expect 200 + curl -i "http://127.0.0.1:/acp?token=" # expect 406 + ``` + + `406` on `/acp` means the secret was accepted (only the SSE header is missing — + which the real client sends); `401`/`403` means the secret is wrong. After + correcting the Secret Key, **fully Quit (Cmd-Q) and reopen** Desktop — it reads + the external-server settings only at startup. + +## Server did not start + +Check the server log: + +```bash +acq exec -- sh -c 'tail -n 40 ~/.local/state/goose/goose-serve.log' +``` + +Common causes: + +- **goose CLI not installed.** The install runs at create time and is non-fatal, + so a transient download failure leaves the binary absent. Check: + + ```bash + acq exec -- sh -c 'command -v goose && goose --version || echo MISSING' + ``` + + If `MISSING`, the create-time download or sha256 verification failed. Re-create + the sandbox (a transient network/registry blip), or verify egress to + `github.com` / `objects.githubusercontent.com` is allow-listed (it is, by this + kit) and that the pinned version/hashes in `spec.yaml` still match the release + assets. On an HTTPS-inspected network, also apply the `zscaler-ca-certificate` + kit so the download trusts the inspection CA. + +- **Wrong-architecture binary.** msb microVMs are aarch64 on Apple Silicon. The + installer selects the asset by `uname -m` and refuses a mismatched sha, so a + wrong-arch install shows up as "does not execute" in the create-time output, not + a running-but-broken binary. + +## goose Desktop says "unable to connect" but the server is healthy + +If the host `curl .../status` returns `ok` (server up, port reachable) yet Desktop +reports "unable to connect" — or won't let you into Settings — the usual cause is a +**provider-name mismatch**. goose Desktop drives the server by *named provider*. If +Desktop asks for a provider the server hasn't defined, agent initialization fails. + +This kit defines a custom provider named **`custom_usai`** on the server. Confirm: + +```bash +# The server must have the custom_usai provider defined: +acq exec -- sh -c 'cat ~/.config/goose/custom_providers/custom_usai.json | head' +# ...and the config must mark it active: +acq exec -- sh -c 'grep active_provider ~/.config/goose/config.yaml' +``` + +In goose Desktop, make sure the selected provider is **`custom_usai`** (USAi) with a +model like `claude-sonnet-5`. If your Desktop uses a differently-named provider, +either rename it to `custom_usai` or edit the kit's shipped `custom_usai.json` + +config primer to match your Desktop's provider name. A full **Quit + reopen** of +Desktop is required after changing the external-server or provider setting. + +## goose runs but every model call fails (auth / 401 / 404) + +The provider is the shipped `custom_usai` custom provider (OpenAI-compatible), +pointed at USAi. Its key env var is `CUSTOM_USAI_API_KEY`. + +- **Missing key.** Confirm you stored the custom secret under the right env name: + + ```bash + acq secret set -g goose-usai --host api.gsa.usai.gov --env CUSTOM_USAI_API_KEY + ``` + + Verify the env var is present in the guest (the value is a proxied placeholder, + not the real key — that is expected): + + ```bash + acq exec -- sh -c 'test -n "$CUSTOM_USAI_API_KEY" && echo SET || echo UNSET' + ``` + +- **404 on model calls.** Usually the provider's `base_url` is wrong for the + gateway. This kit sets `https://api.gsa.usai.gov/api/v1/chat/completions` in + `custom_usai.json`; if USAi changes its path, update that file. A `401` instead + means the key/host binding is off — re-check step 1. + +- **Wrong model id.** The default `GOOSE_MODEL` is `claude-sonnet-5`. Pick a + model present in `custom_usai.json` and served by your USAi tenant (goose + Desktop → Models, or set `GOOSE_MODEL`). + +## Config merge warnings + +`~/.local/state/goose/goose-config-merge.log` records the merge and the +provider-staging step. If it reports "existing goose config uses an unmodeled +shape", a pre-existing `~/.config/goose/config.yaml` used nested maps/lists the +fail-safe flat merger does not model; it appended only the missing kit keys rather +than risk corrupting your config. Inspect the file and set +`active_provider: custom_usai` / `GOOSE_MODEL` by hand if needed. + +## Where things live + +| What | Path (in guest) | +|---|---| +| Server log | `~/.local/state/goose/goose-serve.log` | +| Config-merge + provider-stage log | `~/.local/state/goose/goose-config-merge.log` | +| goose global config | `~/.config/goose/config.yaml` | +| custom_usai provider (on server) | `~/.config/goose/custom_providers/custom_usai.json` | +| goose CLI | `/usr/local/bin/goose` | +| Staged USAi primer | `~/goose-config/config.yaml` | +| Staged custom_usai provider | `~/goose-config/custom_providers/custom_usai.json` | diff --git a/integrations/isolation/acq-kits/goose-server/docs/decisions/0001-goosed-in-sandbox-desktop-on-host.md b/integrations/isolation/acq-kits/goose-server/docs/decisions/0001-goosed-in-sandbox-desktop-on-host.md new file mode 100644 index 00000000..475dc7b0 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/docs/decisions/0001-goosed-in-sandbox-desktop-on-host.md @@ -0,0 +1,80 @@ +# Decision: run goosed in the sandbox, goose Desktop on the host + +**Status:** accepted + +## Context + +We want to extend the `acq` sandbox platform beyond coding-agent CLI harnesses to +a **chat harness** — specifically the [goose](https://github.com/aaif-goose/goose) +desktop client. The goal is to get goose's agent capabilities (interactive loop, +tool execution, MCP extensions, filesystem/shell access) under acq's isolation +controls without virtualizing a desktop GUI inside the sandbox. + +goose has three architectural parts (per its +[architecture guide](https://goose-docs.ai/docs/goose-architecture/)): + +1. **Interface** — the desktop app or CLI. Collects input, renders output. +2. **Agent / server** — `goose serve` (the "goosed" ACP server). Runs the loop, + executes tools, connects extensions, touches the filesystem, calls the LLM. +3. **Extensions** — MCP servers the agent invokes. + +Only part (2) has real blast radius (files, shell, network egress, credentials). +Part (1) is a renderer. + +Crucially, goose **already supports** running the server separately from the UI: +the [remote-server guide](https://goose-docs.ai/docs/guides/remote-goose-server) +documents pointing goose Desktop at an external `goose serve` via +**Settings → goose Server → external server** (URL + optional secret + optional +cert fingerprint). So the split we want is a first-class, supported deployment +mode — not a hack. + +Alternatives considered: + +- **Virtualize the whole desktop app in the sandbox** (X/VNC/RDP). Heavy, poor UX, + and it drags a GUI stack into a security boundary that exists to be minimal. + Rejected. +- **Run goose entirely on the host.** Then none of acq's controls apply — the + whole point of the exercise is lost. Rejected. +- **Run the goosed server in the sandbox, keep the native UI on the host** + (this decision). The isolated part is exactly the part with blast radius; the + UI stays native and pleasant. Matches goose's documented remote-server mode. + +## Decision + +**Put `goose serve` (goosed) inside the acq sandbox and publish its port to the +host; the user's native goose Desktop connects to it as an external server.** + +Concretely, this kit: + +- installs a pinned goose CLI in the guest (create time, sha256-verified), +- supervises `goose serve --host 0.0.0.0 --port 3284` on every start (respawn + loop, held open by the sandbox keepalive, so a detached `acq create` is enough), +- publishes container port 3284 to the host via the neutral `publishedPorts`, +- the operator points goose Desktop at `http://127.0.0.1:`. + +Structurally this is the same pattern as the sibling `openchamber` and `paseo` +kits (supervise a long-lived server in the guest, publish one host-loopback port), +with one difference: the **client is a native desktop app**, not a browser UI +served from the guest. That makes the guest side simpler (no UI to install +in-guest) at the cost of one manual "paste the URL into Desktop settings" step. + +## Consequences + +- All agent work — the interactive loop, tool execution, MCP extensions, file + edits, shell commands, LLM egress — happens inside the sandbox, under acq's + controls (ephemeral container/microVM, deny-default allow-listed egress, no host + FS, injected credentials). The desktop app is a thin renderer. +- The host↔guest link and auth posture are their own decisions + (`0002-plaintext-http-loopback.md`), as is the LLM provider wiring + (`0003-usai-openai-compatible-provider.md`). +- This kit rides on the `shell` agent for now (Increment A). Promoting `goose` to + a first-class acq harness token (`acq run goose `) is a separate, tracked + increment in the quickstart repo, because a first-class harness normally attaches + an interactive TUI, whereas here the "agent" is a background server driven by a + host UI — the attach semantics need their own design. **Note:** the team's + in-flight direction favors `acq` *inferring* the kit from the command over adding + per-agent tokens, which reframes that increment — see + `0004-alignment-with-devenv-harness-direction.md`. +- MCP **extensions** goose loads run in the guest and make their own egress; any + new extension host must be added to the kit's `caps.network.allow` (deny-default). + This is deferred until an extension is actually configured. diff --git a/integrations/isolation/acq-kits/goose-server/docs/decisions/0002-plaintext-http-loopback.md b/integrations/isolation/acq-kits/goose-server/docs/decisions/0002-plaintext-http-loopback.md new file mode 100644 index 00000000..f471ca7b --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/docs/decisions/0002-plaintext-http-loopback.md @@ -0,0 +1,82 @@ +# Decision: plaintext HTTP + shared secret-key handshake over a host-loopback port + +**Status:** accepted (revised after a live goose Desktop 1.50.0 test; supersedes the +initial "unauthenticated" decision) + +## Context + +goosed (`goose serve`) is reached by goose Desktop over the host↔guest link. goose +supports two transports (per the +[remote-server guide](https://goose-docs.ai/docs/guides/remote-goose-server)): + +1. **Plaintext HTTP**, with a shared secret (`GOOSE_SERVER__SECRET_KEY`, sent by the + client as `X-Secret-Key` on `/status` and as `?token=` on `/acp`), or + `--dangerously-unauthenticated` to skip the secret. +2. **Self-signed TLS** (`--tls`), which logs a `GOOSED_CERT_FINGERPRINT` that + Desktop can pin. + +acq publishes the guest port to the **host loopback** only (127.0.0.1). + +### What we tried first, and why it failed + +The initial version ran `--dangerously-unauthenticated`, reasoning that the sandbox +is the security boundary and a static secret in the kit env is not a "real" secret +(and acq cannot inject a host-less secret through its credential path). A live test +against **goose Desktop 1.50.0** disproved the *connectivity* assumption: + +- The server was fully healthy: `GET /status` → `200 OK`, `GET /acp` (no SSE header) + → **406** ("client must accept text/event-stream"), i.e. the ACP endpoint was live + and, per the goose docs, 406 means *auth passed*. +- Yet Desktop reported **"server cannot connect"** and made **no** TCP connection to + the port on launch. + +Inspecting the Desktop app bundle (`app.asar`) showed why: its ACP client's health +check **always** sends `X-Secret-Key: ` to `/status` and builds the ACP URL +as `/acp?token=`. Its own `goose serve` spawn path sets +`GOOSE_SERVER__SECRET_KEY` unconditionally (`ve()` requires a non-empty secret and +throws "GOOSE_SERVER__SECRET_KEY is required for goose serve" otherwise). Desktop +1.50.0 has **no supported path for an empty-secret external server** — so an +unauthenticated server, however healthy, is unusable from Desktop. + +### The secret is not a protectable credential + +`GOOSE_SERVER__SECRET_KEY` must ALSO be entered into Desktop's settings by the +operator, and it guards only a **host-loopback** port. It is a **handshake token**, +not a credential protecting external egress. acq's secret path (which requires a +host binding and could not inject a host-less value) is therefore both unavailable +*and* unnecessary here: there is nothing to protect that a cleartext env value +weakens. + +## Decision + +**Run `goose serve` WITH a shared secret from `GOOSE_SERVER__SECRET_KEY`, set in the +kit's non-secret `environment:` block, over plaintext HTTP on a host-loopback +published port.** The operator pastes the same value into goose Desktop → +Settings → goose Server → Secret Key. + +- Default value: `goose-server-kit-local-loopback`. Operators MAY override it per + sandbox (`-e GOOSE_SERVER__SECRET_KEY=`), setting the same value in Desktop. +- The secret is passed via the environment (goose reads it), not on argv, to keep it + out of the process command line and logs. + +This reverses the earlier "unauthenticated" decision on the strength of the live +finding: Desktop 1.50.0 requires the handshake, so matching it is a correctness +requirement, not a security upgrade. + +## Consequences + +- goose Desktop 1.50.0 connects; the earlier "server cannot connect" is resolved. +- The link is **not** encrypted, and the "secret" is cleartext in the kit env and in + Desktop's settings. This is acceptable only because the port is **loopback-only** + and the **sandbox is the real security boundary** (ephemeral container/microVM, + deny-default allow-listed egress, no host FS, injected credentials). It is **not** + a posture for exposing goosed on a network interface. +- The verify script asserts the **handshake**: `/status` returns 200 **with** the + kit secret, and `/acp?token=` returns **406** (auth passed) — not + 401/403 (secret rejected). +- If a future requirement needs real network exposure or defense against other local + users on a shared host, adopt the **TLS + fingerprint** follow-up: run + `goose serve --tls`, capture the logged `GOOSED_CERT_FINGERPRINT`, and pin it in + Desktop (Desktop also supports HTTPS external URLs). That path does not need acq to + inject a secret (the fingerprint is not a secret). Tracked as a follow-up + increment. diff --git a/integrations/isolation/acq-kits/goose-server/docs/decisions/0003-usai-openai-compatible-provider.md b/integrations/isolation/acq-kits/goose-server/docs/decisions/0003-usai-openai-compatible-provider.md new file mode 100644 index 00000000..cc952ad0 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/docs/decisions/0003-usai-openai-compatible-provider.md @@ -0,0 +1,90 @@ +# Decision: wire USAi via a goose custom provider (`custom_usai`) matching Desktop + +**Status:** accepted (supersedes the initial "built-in `openai` provider" approach +after a live goose Desktop test) + +## Context + +goosed needs an LLM provider, and — because this is a *chat harness* driven by a +**goose Desktop** client connected to the external server — the provider the +**server** exposes must match the provider the **Desktop** selects. goose Desktop +drives the external `goose serve` by *named provider + model*; if it asks for a +provider the server has not defined, agent initialization fails and Desktop reports +"unable to connect" even though the server is healthy. + +The GSA ecosystem targets the **USAi** gateway (`api.gsa.usai.gov`), which is +OpenAI-compatible. A real GSA goose Desktop reaches it through a goose **custom +provider** named **`custom_usai`** (engine `openai`), defined in +`~/.config/goose/custom_providers/custom_usai.json`: + +```json +{ + "name": "custom_usai", + "engine": "openai", + "api_key_env": "CUSTOM_USAI_API_KEY", + "base_url": "https://api.gsa.usai.gov/api/v1/chat/completions", + "models": [ { "name": "claude_4_5_sonnet", ... }, ... ], + "requires_auth": true +} +``` + +An initial version of this kit configured the server with goose's **built-in +`openai`** provider (`GOOSE_PROVIDER=openai` + `OPENAI_HOST`/`OPENAI_BASE_PATH`, key +as `OPENAI_API_KEY`). A live Desktop test disproved that approach: Desktop asked +for provider `custom_usai`, the server only had `openai`, the session failed to +initialize, and Desktop showed "unable to connect" (host `curl .../status` returned +`200 OK` the whole time — the failure was purely the provider-identity mismatch). + +We also verified acq's secret injection: a **custom acq service** lets the operator +choose the injected env-var name via `--env`, honored end-to-end (the `(host, env)` +sidecar is read first, msb binds `--secret @`), so the guest can receive +the key under whatever name the provider's `api_key_env` requires. + +## Decision + +**Reproduce the `custom_usai` custom provider on the server, and inject the key +under its `api_key_env` name (`CUSTOM_USAI_API_KEY`).** + +Concretely, the kit: + +1. Ships `files/home/goose-config/custom_providers/custom_usai.json` — the exact + `custom_usai` definition (name, engine `openai`, `base_url`, model list), + **minus the secret**. The startup script copies it into the guest's + `~/.config/goose/custom_providers/` (idempotently; it does not clobber a + provider the user later edited in Desktop). +2. Marks `custom_usai` active in the config primer (`active_provider: custom_usai`, + default `GOOSE_MODEL: claude-sonnet-5`). +3. Injects the API key as **`CUSTOM_USAI_API_KEY`** — the provider's `api_key_env` — + via a custom acq secret service bound to `api.gsa.usai.gov`, never stored in the + kit: + ``` + acq secret set -g goose-usai --host api.gsa.usai.gov --env CUSTOM_USAI_API_KEY + ``` + +No `OPENAI_*` env and no built-in `openai` provider are used — that is a *different* +provider identity than the one Desktop names. + +## Consequences + +- Desktop configured with `custom_usai` connects to the server without + reconfiguration; the two provider identities agree. +- The key value never enters the guest in cleartext or the kit spec; msb swaps it + on the wire only for `api.gsa.usai.gov` (TLS-intercepted), matching the + usai-provider/mcp-gateway secret model. +- `caps.network.allow` must include `api.gsa.usai.gov` (it does), since egress is + deny-default. +- **Provider-name coupling:** a Desktop using a *differently-named* provider (not + `custom_usai`) still won't connect. The README/TROUBLESHOOTING call this out; the + fix is to align the names (rename in Desktop, or edit the shipped + `custom_usai.json` + primer). This is inherent to goose's named-provider model, + not a kit defect. +- If USAi changes its base path/host, update `custom_usai.json` (`base_url`) and the + secret's `--host`. A `404` on model calls signals a wrong `base_url`; a `401` + signals a wrong key/host binding. +- The default model (`claude-sonnet-5`) must be one the operator's USAi tenant + serves and must be present in `custom_usai.json`; it is overridable in Desktop or + via `GOOSE_MODEL`. +- The config primer stays a **flat** top-level YAML map (no nested `providers:` + block) so the kit's fail-safe flat-YAML merger can merge it into a pre-existing + config without touching an unmodeled nested shape; the provider *connection* + details live in `custom_usai.json`, not the primer. diff --git a/integrations/isolation/acq-kits/goose-server/docs/decisions/0004-alignment-with-devenv-harness-direction.md b/integrations/isolation/acq-kits/goose-server/docs/decisions/0004-alignment-with-devenv-harness-direction.md new file mode 100644 index 00000000..aced252a --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/docs/decisions/0004-alignment-with-devenv-harness-direction.md @@ -0,0 +1,87 @@ +# Decision: alignment with the devenv / harness-adapter direction (forward work) + +**Status:** accepted (records known divergences from an in-flight architecture +direction; not a change to the kit's current behavior) + +## Context + +While this kit was being built, the team opened a design discussion about the +direction of `acq` and kits. Two proposals are relevant to `goose-server`: + +1. **Generic devenv base image, agent knowhow in kits.** Stop baking agents into + backend base images. Boot a single generic **devenv** image (Nix + devenv + + direnv, agent-less) on both backends, and move ALL agent install/config/startup + knowhow — including OpenCode's — into kits that drop **devenv** configuration. + `acq` runs the devenv environment after boot. Goal: shrink bespoke per-kit code + and per-agent special-casing in `acq`, and lean on the devenv ecosystem. + +2. **Layered model with a harness-adapter layer.** Control Plane → Harness Adapter + Layer (OpenCode │ Pi │ Goose │ Prime │ …) → Environment/Capability Layer (Kits + + devenv) → Sandbox Control Layer (`acq` → msb/sbx → generic devenv base). `acq` + MAY *infer* the agent kit from the invoked command; `shell` is the only special + case (no per-agent tokens baked into `acq`). + +A specific call-out in that discussion: the hand-rolled supervisor loop in the +openchamber/paseo kits was named as "clumsy" precedent to be replaced by a +standardized **devenv process-supervision** method. + +This kit was authored against the CURRENT reality (hybrid/v1 kits, backend base +images, imperative install + a shell respawn loop), so it diverges from that +direction in three concrete places. This ADR records them so the divergence is +tracked, not silently carried as debt. + +## Decision + +**Ship the kit as-is now** (it is a working, tested, kit-only integration and the +best concrete example to inform the very discussion above), **and record the +following migrations to perform once the devenv base-image and harness-adapter +decisions land.** Do not pre-build against those decisions while they are in flux +(YAGNI); revisit when they are accepted. + +### Divergence 1 — supervisor loop → devenv process supervision + +`files/home/goose-start.sh` supervises `goose serve` with a `while :; do … ; sleep +; done` respawn loop — the same pattern flagged for replacement in openchamber/ +paseo. When a standardized devenv process-supervision method exists, the goosed +server SHOULD be expressed as a devenv `process` (process-compose) instead, and the +respawn loop deleted. The config-merge + provider-stage steps would become devenv +enterShell/activation steps. + +### Divergence 2 — first-class token → kit inference (reframes Increment B) + +The initial plan for Increment B was to add a `goose` token to `acq`'s +`ACQ_KNOWN_AGENTS` + an msb install recipe (`GSA-TTS/agentic-coding-quickstart#466`). +That ADDS per-agent special-casing to `acq`, which is the OPPOSITE of the proposed +direction. Under the proposal, `acq run goose ` should work by `acq` +**inferring the `goose-server` kit** from the command, not by teaching `acq` about a +`goose` agent. Increment B should be reframed accordingly; until kit-inference +exists, the kit-on-`shell` path (Increment A, shipped here) remains the supported +entry point. + +### Divergence 3 — imperative install → declarative devenv package + +`files/home/goose-install.sh` fetches + sha-verifies + installs the goose binary +imperatively at create time. Under the devenv model, goose SHOULD be expressed +declaratively (a devenv `packages`/flake input, or an overlay), gaining Nix's +caching and reproducibility and removing the bespoke installer. The arch-selection +and pinning concerns move into the Nix expression. + +## Consequences + +- The kit works today on the current stack (hybrid/v1, backend base images) with no + `acq` core changes — it is committable and useful now, and demonstrates that a new + harness *category* (a chat client with an out-of-sandbox GUI) fits the kit layer. +- Three tracked migrations remain, each gated on the corresponding upstream + decision: (1) devenv process supervision, (2) kit-inference reframing of Increment + B, (3) declarative devenv install. Each SHOULD be filed as a tracked item when its + gating decision is accepted (do not file speculative work before the trigger). +- **goose-server stresses the harness-adapter abstraction.** Unlike OpenCode/Pi/ + Prime — TUI agents `acq` attaches to and drives — goose here is a **background + server whose client is a native goose Desktop app on the host**, reached over a + published loopback port with a secret-key handshake and a provider-identity + contract (see ADR-0002, ADR-0003). `acq` is NOT the client. This is a useful data + point for the "Harness Adapter Layer": its "prompt · events · tools · status · + resume" session API assumes an agent the control plane drives directly, which does + not describe Goose-via-Desktop. Whether Goose is a distinct harness *category* or + sits outside the adapter layer (human drives Desktop directly) is an open design + question this kit surfaces. diff --git a/integrations/isolation/acq-kits/goose-server/files/home/goose-config/config.yaml b/integrations/isolation/acq-kits/goose-server/files/home/goose-config/config.yaml new file mode 100644 index 00000000..cdb78f7c --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/files/home/goose-config/config.yaml @@ -0,0 +1,56 @@ +# config.yaml — goose primer for the GSA USAi provider (staged, not the live path). +# +# This file is DROPPED into the sandbox at /home/agent/goose-config/config.yaml +# (a staging area) and MERGED into goose's real global config +# (~/.config/goose/config.yaml) at startup by merge-config.mjs, WITHOUT clobbering +# any global config the base image or another kit may already have written. See +# the goose-server kit README "Design: merge, don't clobber" and +# docs/decisions/0003-usai-openai-compatible-provider.md. +# +# WHY A CUSTOM PROVIDER (custom_usai), NOT the built-in `openai` provider (learned +# from a live Desktop test): goose Desktop drives the server by NAMED provider + +# model. A real GSA goose Desktop is configured with a CUSTOM provider named +# `custom_usai` (an OpenAI-compatible custom provider defined in +# ~/.config/goose/custom_providers/custom_usai.json), selecting models like +# `claude_4_5_sonnet` / `claude-sonnet-5`. When Desktop connects to THIS external +# server and asks for provider `custom_usai`, the server must ALSO define +# `custom_usai` or agent init fails and Desktop cannot connect. So the kit ships +# the matching custom_usai.json (see files/home/goose-config/custom_providers/) and +# this primer marks custom_usai active — the two now agree. The built-in `openai` +# provider is deliberately NOT used, because it is a DIFFERENT provider identity +# than the one Desktop names. +# +# THE ONLY SECRET is the API key, read by goose from the env var named in the +# custom provider's api_key_env: CUSTOM_USAI_API_KEY. acq injects it at runtime +# bound to api.gsa.usai.gov (never stored in this kit). Register it once: +# acq secret set -g goose-usai --host api.gsa.usai.gov --env CUSTOM_USAI_API_KEY +# +# So the live config is: this primer selects the provider/model, the shipped +# custom_usai.json defines the provider connection (base_url + models), and acq +# injects CUSTOM_USAI_API_KEY. No OPENAI_* env is needed or set. +# +# goose-server:owns-goose-config +# ^ Ownership marker COMMENT (mirrors the usai-provider kit's marker). Tags this +# file as the one this kit ships so the startup merge + scripts/verify can +# recognize it after a verbatim copy into the global path. YAML comments survive a +# verbatim copy but are dropped on a merge (the merge emits normalized YAML), same +# convergence behavior as the usai-provider kit. Do not remove this comment. + +# Active provider selection. `custom_usai` matches the custom provider Desktop +# uses AND the shipped custom_providers/custom_usai.json (which carries the +# base_url + model list). We keep this primer FLAT (top-level scalar keys only) — +# no nested `providers:` map — so the kit's fail-safe flat-YAML merger +# (merge-config.mjs) can merge it into a pre-existing config without touching an +# unmodeled nested shape. The provider CONNECTION details live in custom_usai.json, +# not here; this primer only needs to name the active provider + default model. +active_provider: custom_usai + +# Default model (goose reads GOOSE_MODEL from env too; kept here as belt-and- +# suspenders). Must be a model id present in custom_usai.json. +GOOSE_MODEL: claude-sonnet-5 + +# Tool execution mode. "smart_approve" asks before higher-risk tool calls; the +# sandbox is the containment boundary, but goose Desktop is a human-in-the-loop UI, +# so a smart-approve default gives the operator visibility without a prompt on +# every benign call. Operators can change this in Desktop or via GOOSE_MODE. +GOOSE_MODE: smart_approve diff --git a/integrations/isolation/acq-kits/goose-server/files/home/goose-config/custom_providers/custom_usai.json b/integrations/isolation/acq-kits/goose-server/files/home/goose-config/custom_providers/custom_usai.json new file mode 100644 index 00000000..d4bc3261 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/files/home/goose-config/custom_providers/custom_usai.json @@ -0,0 +1,160 @@ +{ + "name": "custom_usai", + "engine": "openai", + "display_name": "USAi", + "description": "Custom USAi provider (GSA USAi OpenAI-compatible gateway). Shipped by the goose-server acq kit so the in-sandbox goosed server exposes the SAME provider a goose Desktop client already uses, letting Desktop drive the external server without reconfiguring. The API key is NOT in this file: goose reads it from the CUSTOM_USAI_API_KEY env var (api_key_env), which acq injects at runtime bound to api.gsa.usai.gov. GENERATED from the USAi catalog by the goose emitter — do not hand-edit; regenerate via the emitter.", + "api_key_env": "CUSTOM_USAI_API_KEY", + "base_url": "https://api.gsa.usai.gov/api/v1/chat/completions", + "models": [ + { + "name": "claude-opus-5", + "context_limit": 1000000, + "input_token_cost": 0.000005, + "output_token_cost": 0.000025, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "claude-sonnet-5", + "context_limit": 1000000, + "input_token_cost": 0.000002, + "output_token_cost": 0.00001, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "claude_4_8_opus", + "context_limit": 1000000, + "input_token_cost": 0.000005, + "output_token_cost": 0.000025, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "claude_4_7_opus", + "context_limit": 1000000, + "input_token_cost": 0.000005, + "output_token_cost": 0.000025, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "claude_4_5_sonnet", + "context_limit": 200000, + "input_token_cost": 0.000003, + "output_token_cost": 0.000015, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "claude_4_5_haiku", + "context_limit": 200000, + "input_token_cost": 0.000001, + "output_token_cost": 0.000005, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gpt_5_5_default_v2", + "context_limit": 1050000, + "input_token_cost": 0.000005, + "output_token_cost": 0.00003, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gpt_5_4_default_v2", + "context_limit": 1050000, + "input_token_cost": 0.0000025, + "output_token_cost": 0.000015, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gpt_5_2_default_v2", + "context_limit": 400000, + "input_token_cost": 0.00000175, + "output_token_cost": 0.000014, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gemini-2.5-pro", + "context_limit": 1048576, + "input_token_cost": 0.00000125, + "output_token_cost": 0.00001, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gemini-2.5-flash", + "context_limit": 1048576, + "input_token_cost": 3e-7, + "output_token_cost": 0.0000025, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gemini-2.5-flash-lite", + "context_limit": 1048576, + "input_token_cost": 1.0000000000000001e-7, + "output_token_cost": 4.0000000000000003e-7, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gemini-3.5-flash", + "context_limit": 1048576, + "input_token_cost": 0.0000015, + "output_token_cost": 0.000009, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "gemini-3-7-flash", + "context_limit": 1000000, + "input_token_cost": 0.000001875, + "output_token_cost": 0.000009375, + "currency": "USD", + "supports_cache_control": true, + "reasoning": false + }, + { + "name": "llama_4_maverick", + "context_limit": 1000000, + "input_token_cost": 2.4e-7, + "output_token_cost": 9.7e-7, + "currency": "USD", + "supports_cache_control": null, + "reasoning": false + } + ], + "headers": null, + "timeout_seconds": null, + "supports_streaming": true, + "requires_auth": true, + "catalog_provider_id": null, + "base_path": null, + "env_vars": null, + "dynamic_models": null, + "skip_canonical_filtering": false, + "model_doc_link": null, + "setup_steps": [], + "fast_model": null, + "preserves_thinking": true, + "emit_clear_thinking": false, + "setup": null +} diff --git a/integrations/isolation/acq-kits/goose-server/files/home/goose-config/merge-config.mjs b/integrations/isolation/acq-kits/goose-server/files/home/goose-config/merge-config.mjs new file mode 100644 index 00000000..5f555ff3 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/files/home/goose-config/merge-config.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node +// +// merge-config.mjs — land the goose USAi primer at goose's GLOBAL config path +// (~/.config/goose/config.yaml) WITHOUT clobbering a global config the base image +// or another kit may already have written. +// +// This mirrors the usai-provider kit's merge-global-config.mjs, but for goose's +// config, which is YAML (goose uses ~/.config/goose/config.yaml) rather than +// OpenCode's JSONC. See the goose-server kit README "Design: merge, don't +// clobber" and docs/decisions/0003-usai-openai-compatible-provider.md. +// +// Behavior: +// - No existing global config -> COPY the kit's file verbatim (comments and +// the ownership marker preserved). No merge needed. +// - Existing global config -> parse both as YAML, DEEP-MERGE the kit's keys +// INTO the existing config (kit wins for its OWN keys; unrelated existing keys +// preserved), and write normalized YAML. A `warning:` is printed to stderr for +// every top-level leaf the kit overrides. Comments are lost in this branch +// (the emitted YAML has none); the annotated source stays at the staged path. +// +// Convergence (same as the usai-provider kit): on an empty global dir the first +// boot COPIES verbatim (comments intact); the next boot re-reads that output as an +// existing config and takes the MERGE branch, normalizing to bare YAML. From then +// on every boot re-writes byte-identical YAML. The effective config is stable at +// every step. +// +// DEPENDENCY NOTE: goose's config is YAML. To avoid adding a YAML library +// dependency to the sandbox (npm install behind the proxy is exactly the failure +// mode the openchamber/paseo kits defer to startup to avoid), this script uses a +// SMALL, DEPENDENCY-FREE parser/emitter that supports the FLAT `KEY: value` shape +// this kit's primer and goose's own `goose configure` output use (goose writes a +// flat map of scalar settings; nested provider blocks are not written to +// config.yaml — connection details come from env). If an existing config uses a +// shape this minimal parser cannot represent (nested maps, lists, block scalars), +// the script DOES NOT guess: it leaves the existing config untouched and appends +// only the kit keys that are absent, logging what it skipped. This is fail-safe: +// it never corrupts a richer config it cannot fully model. +// +// Usage: +// node merge-config.mjs \ +// --source /home/agent/goose-config/config.yaml \ +// --global-dir /home/agent/.config/goose +// +// Exit codes: 0 on success (copy or merge). Non-zero only on unexpected error +// (e.g. the source is missing). Leaf-conflict warnings do NOT fail. + +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises" +import path from "node:path" +import process from "node:process" + +const GLOBAL_FILENAME = "config.yaml" + +/** + * Parse a FLAT `KEY: value` YAML document (the shape goose's config.yaml uses: + * a top-level map of scalar settings). Returns { map, flat } where `flat` is true + * only if every non-blank, non-comment line was a simple `key: scalar` pair. When + * `flat` is false the caller must NOT treat `map` as a complete representation. + * Comments (`#`) and blank lines are ignored. Quoted scalars are unquoted. + * @param {string} text + * @returns {{ map: Record, flat: boolean }} + */ +export function parseFlatYaml(text) { + const map = {} + let flat = true + for (const rawLine of text.split("\n")) { + const line = rawLine.replace(/\r$/, "") + const trimmed = line.trim() + if (trimmed === "" || trimmed.startsWith("#")) continue + // A line that is indented, starts a list, or has no top-level `key:` is a + // shape this minimal parser does not model. + if (/^\s/.test(line) || trimmed.startsWith("- ")) { + flat = false + continue + } + const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(trimmed) + if (!m) { + flat = false + continue + } + let value = m[2] + // Strip a trailing inline comment only when the value is unquoted. + if (!(value.startsWith('"') || value.startsWith("'"))) { + const hash = value.indexOf(" #") + if (hash !== -1) value = value.slice(0, hash).trim() + } + // Unquote. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + if (value === "") { + // `key:` with no value is a nested-map/opener — not a flat scalar. + flat = false + continue + } + map[m[1]] = value + } + return { map, flat } +} + +/** + * Emit a flat map as YAML. Scalars that need quoting (contain a colon-space, + * leading special char, or are ambiguous) are double-quoted. + * @param {Record} map + * @returns {string} + */ +export function emitFlatYaml(map) { + const needsQuote = (v) => + v === "" || + /^[\s>|&*!%@`"'#-]/.test(v) || + /:\s/.test(v) || + /[:#]$/.test(v) || + /^(true|false|null|yes|no|~)$/i.test(v) || + /^[0-9.]+$/.test(v) === false && /[:{}\[\],]/.test(v) + const lines = [] + for (const key of Object.keys(map)) { + const v = String(map[key]) + lines.push(`${key}: ${needsQuote(v) ? JSON.stringify(v) : v}`) + } + return lines.join("\n") + "\n" +} + +function parseArgs(argv) { + const args = { source: "", globalDir: "" } + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--source") args.source = argv[++i] + else if (argv[i] === "--global-dir") args.globalDir = argv[++i] + } + return args +} + +/** + * Compute the merge result. Pure, for unit-testing. + * @param {string} sourceText - the kit's config.yaml + * @param {string|null} existingText - the existing global config, or null + * @returns {{ output: string, merged: boolean, conflicts: string[], skipped: boolean }} + */ +export function computeOutput(sourceText, existingText) { + if (existingText == null) { + // Copy verbatim — preserves comments and the ownership marker. + return { output: sourceText, merged: false, conflicts: [], skipped: false } + } + const src = parseFlatYaml(sourceText) + const existing = parseFlatYaml(existingText) + + // If the EXISTING config uses a shape we cannot fully model, do not rewrite it + // wholesale. Append only the kit keys that are absent, by concatenation, so we + // never corrupt a richer config. This is the fail-safe branch. + if (!existing.flat) { + const missing = Object.keys(src.map).filter( + (k) => !new RegExp(`^\\s*${k}\\s*:`, "m").test(existingText), + ) + if (missing.length === 0) { + return { output: existingText, merged: true, conflicts: [], skipped: true } + } + const appended = + existingText.replace(/\n*$/, "\n") + + "\n# --- appended by goose-server kit (existing config uses an unmodeled shape) ---\n" + + emitFlatYaml(Object.fromEntries(missing.map((k) => [k, src.map[k]]))) + return { output: appended, merged: true, conflicts: [], skipped: true } + } + + // Flat merge: kit wins for its own keys; unrelated existing keys preserved. + const conflicts = [] + const out = { ...existing.map } + for (const key of Object.keys(src.map)) { + if ( + Object.prototype.hasOwnProperty.call(existing.map, key) && + existing.map[key] !== src.map[key] + ) { + conflicts.push(key) + } + out[key] = src.map[key] + } + return { output: emitFlatYaml(out), merged: true, conflicts, skipped: false } +} + +async function main() { + const { source, globalDir } = parseArgs(process.argv.slice(2)) + if (!source || !globalDir) { + throw new Error("usage: merge-config.mjs --source --global-dir ") + } + + const sourceText = await readFile(source, "utf8") + + let existingText = null + try { + const entries = await readdir(globalDir) + if (entries.includes(GLOBAL_FILENAME)) { + existingText = await readFile(path.join(globalDir, GLOBAL_FILENAME), "utf8") + } + } catch { + // globalDir does not exist yet -> treated as no existing config. + } + + const { output, merged, conflicts, skipped } = computeOutput(sourceText, existingText) + + await mkdir(globalDir, { recursive: true }) + const target = path.join(globalDir, GLOBAL_FILENAME) + await writeFile(target, output) + + if (!merged) { + process.stdout.write( + `goose-server: no existing goose config; copied USAi primer to ${target}\n`, + ) + return + } + if (skipped) { + process.stdout.write( + `goose-server: existing goose config uses an unmodeled shape; appended only missing kit keys to ${target}\n`, + ) + return + } + process.stdout.write( + `goose-server: merged USAi primer into existing goose config at ${target}\n`, + ) + for (const c of conflicts) { + process.stderr.write( + `goose-server: warning: overrode existing goose key '${c}' with the USAi primer value\n`, + ) + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`goose-server: merge failed: ${error.message}`) + process.exit(1) + }) +} diff --git a/integrations/isolation/acq-kits/goose-server/files/home/goose-install.sh b/integrations/isolation/acq-kits/goose-server/files/home/goose-install.sh new file mode 100755 index 00000000..d8226cb6 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/files/home/goose-install.sh @@ -0,0 +1,228 @@ +#!/bin/sh +# goose-install.sh — install the pinned goose CLI binary into the sandbox. +# +# Runs at CREATE time as ROOT (install phase). Detects the guest architecture, +# fetches the matching pinned goose release tarball from GitHub Releases, verifies +# its sha256, extracts the single `goose` binary, and installs it on PATH. +# Non-fatal: any failure warns to stderr and exits 0 so sandbox provisioning still +# succeeds (the kit degrades — `goose` is simply absent and the startup step warns, +# so the sandbox is never dead just because the optional server could not install). +# +# WHY create-time (install phase), unlike the openchamber/paseo kits which install +# at startup: those install npm packages whose native deps can fail behind the +# proxy, so they defer to startup to keep a transient failure OFF the create path. +# goose ships as a SINGLE prebuilt static binary from GitHub Releases (no npm, no +# native build, no toolchain), so the create-time install is a plain, deterministic +# curl+verify+extract with no compile step — and it is still fully non-fatal here, +# so a transient download failure degrades to "server absent", never a dead +# sandbox. Installing at create time means the binary is present before the first +# startup supervises `goose serve`. +# +# ARCHITECTURE (same lesson as the mcp-gateway kit): microsandbox microVMs are +# aarch64 on Apple Silicon hosts and x86-64 on amd64 hosts. Installing a fixed +# amd64 binary on an aarch64 guest fails at runtime with "Exec format error", so +# we SELECT the asset by `uname -m`. goose's own download_cli.sh names the Linux +# assets `goose--unknown-linux-.tar.` where is the +# rust triple arch (x86_64 or aarch64). We follow the same naming. +# +# ARCHIVE FORMAT — GZIP, NOT BZIP2 (learned from a live msb test): goose publishes +# BOTH `.tar.bz2` and `.tar.gz` for every asset. We use the `.tar.gz`, because the +# minimal `shell-docker` sandbox-template (docker.io/docker/sandbox-templates: +# shell-docker) has NO `bzip2` binary — and GNU tar's bzip2 filter (`-j`) shells +# out to the external `bzip2`, so `tar xjf` fails there with the binary absent (the +# create-time download + sha256 verify SUCCEED, then extraction fails, leaving +# goose uninstalled). gzip (`tar xzf`) needs only zlib, which is universally +# present. So we fetch the `.gz` asset and extract with `tar xzf`. +# +# LIBC VARIANT: goose ships `-gnu` (glibc) and `-musl` builds. The acq base images +# (Docker sandbox-templates + the msb opencode image) are Debian/glibc, so we +# install the `-gnu` build by default. GOOSE_LINUX_VARIANT=musl overrides it for a +# musl base. +# +# INTEGRITY: GitHub RELEASE assets are byte-stable (unlike source tarballs, which +# the server recompresses), so we pin each ARCHIVE sha256 directly and verify +# before extracting. The `.tar.gz` sha256 DIFFERS from the `.tar.bz2` sha256 — pin +# the GZIP one. Pins arrive via the environment (with in-script fallbacks kept in +# sync with the kit spec): +# GOOSE_VERSION — release tag, e.g. v1.10.0 (NOT the moving "stable") +# GOOSE_SHA256_LINUX_X86_64 — sha256 of goose-x86_64-unknown-linux-.tar.gz +# GOOSE_SHA256_LINUX_AARCH64 — sha256 of goose-aarch64-unknown-linux-.tar.gz +# To bump: change the version + BOTH hashes in the spec's install command AND the +# fallbacks below, and re-verify against the release assets (the GZIP digests). +# +# WHY WE DO NOT PIPE goose's download_cli.sh: that script defaults to the MOVING +# `stable` tag (not a pinned version), runs `goose configure` interactively, and +# offers to edit shell rc files — none of which suits a reproducible, sha-pinned, +# non-interactive create-time install. We fetch the same release asset directly. + +set -u + +# NON-INTERACTIVE: no terminal at create time. +export GIT_TERMINAL_PROMPT=0 +export GIT_ASKPASS=/bin/false + +ver="${GOOSE_VERSION:-}" +sha_x86_64="${GOOSE_SHA256_LINUX_X86_64:-}" +sha_aarch64="${GOOSE_SHA256_LINUX_AARCH64:-}" +variant="${GOOSE_LINUX_VARIANT:-gnu}" + +warn() { echo "goose-server(install): $*" >&2; } + +# A pinned version + hashes are REQUIRED. Without them we cannot install a +# verified binary; degrade to "server absent" (non-fatal) rather than fetch an +# unpinned/unverified artifact. +if [ -z "$ver" ]; then + warn "GOOSE_VERSION not set; refusing to install an unpinned goose. (non-fatal)" + exit 0 +fi + +case "$variant" in + gnu|musl) ;; + *) warn "unknown GOOSE_LINUX_VARIANT '$variant' (want gnu|musl); defaulting to gnu."; variant="gnu" ;; +esac + +# Map the guest architecture to goose's rust-triple arch + pick its hash. +uname_m="$(uname -m 2>/dev/null || echo unknown)" +case "$uname_m" in + x86_64|amd64) rarch="x86_64"; sha="$sha_x86_64" ;; + aarch64|arm64) rarch="aarch64"; sha="$sha_aarch64" ;; + *) + warn "unsupported guest architecture '$uname_m' (need x86_64/amd64 or aarch64/arm64)." + warn " goose not installed (non-fatal)." + exit 0 + ;; +esac + +if [ -z "$sha" ]; then + warn "no pinned sha256 for arch '$rarch' (variant $variant); refusing to install unverified. (non-fatal)" + exit 0 +fi + +asset="goose-${rarch}-unknown-linux-${variant}.tar.gz" +url="https://github.com/aaif-goose/goose/releases/download/${ver}/${asset}" +# Install into a system PATH dir (root at create time). /usr/local/bin is on PATH +# for the agent user in the base image. +dest_dir="/usr/local/bin" + +# Idempotency: if the pinned goose is already installed AND executes, do nothing. +# Require it to actually run (a wrong-arch binary "exists" but fails Exec format). +if command -v goose >/dev/null 2>&1 && goose --version >/dev/null 2>&1; then + if goose --version 2>/dev/null | grep -q "${ver#v}"; then + echo "goose-server(install): goose $ver ($rarch/$variant) already installed and runnable; skipping." + exit 0 + fi +fi + +if ! command -v curl >/dev/null 2>&1; then + warn "curl not found in base image; cannot download goose. Skipping (non-fatal)." + exit 0 +fi + +# The archive is gzip-compressed (`tar xzf`, zlib only — no external bzip2, which +# the minimal shell-docker template lacks). Fail non-fatally with a clear hint on +# any error. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" 2>/dev/null | cut -d' ' -f1 + fi +} + +tgz="$(mktemp "${TMPDIR:-/tmp}/goose.XXXXXX.tar.gz" 2>/dev/null)" || tgz="/tmp/goose.$$.tar.gz" +cerr="$(mktemp "${TMPDIR:-/tmp}/goose-err.XXXXXX" 2>/dev/null)" || cerr="/tmp/goose-err.$$" +stage="$(mktemp -d "${TMPDIR:-/tmp}/goose-stage.XXXXXX" 2>/dev/null)" || stage="/tmp/goose-stage.$$" + +cleanup() { rm -rf "$tgz" "$cerr" "$stage" 2>/dev/null || true; } +trap cleanup EXIT INT TERM + +# -f fail on HTTP error; -sSL quiet-but-show-errors + follow the releases -> +# objects.githubusercontent.com redirect. Bounded timeouts so a black-holing +# network cannot hang provisioning. Public asset — no auth header. The sandbox +# proxy CA is already trusted in the guest trust store at create time, so curl's +# default verification works behind the inspecting proxy. +curl -fsSL --connect-timeout 15 --max-time 300 -o "$tgz" "$url" 2>"$cerr" +if [ $? -ne 0 ]; then + warn "download of $url failed (offline or bad pin?). goose not installed (non-fatal)." + [ -s "$cerr" ] && warn "curl: $(tr '\n' ' ' < "$cerr")" + exit 0 +fi + +# Verify the archive sha256 BEFORE extracting. +got="$(sha256_of "$tgz")" +if [ -z "$got" ]; then + warn "no sha256 tool available; cannot verify goose archive. Refusing to install (non-fatal)." + exit 0 +fi +if [ "$got" != "$sha" ]; then + warn "goose archive sha256 mismatch for $rarch/$variant:" + warn " expected $sha" + warn " got $got" + warn "Refusing to install a mismatched binary. (non-fatal)" + exit 0 +fi + +# Extract into staging. -z = gzip (zlib only, no external bzip2 binary needed). +# --no-same-owner/--no-same-permissions avoid honoring owner/mode bits from the +# archive. +if ! tar xzf "$tgz" -C "$stage" --no-same-owner --no-same-permissions 2>"$cerr"; then + warn "failed to extract goose archive. (non-fatal)" + [ -s "$cerr" ] && warn "tar: $(tr '\n' ' ' < "$cerr")" + exit 0 +fi + +# The Linux asset extracts a top-level `goose` binary (and, for local-inference +# builds, sidecar libraries). Install the whole extracted tree's `goose` plus any +# co-located shared objects it needs. Find the binary defensively. +goose_bin="" +if [ -f "$stage/goose" ]; then + goose_bin="$stage/goose" +else + goose_bin="$(find "$stage" -type f -name goose 2>/dev/null | head -n1)" +fi +if [ -z "$goose_bin" ] || [ ! -f "$goose_bin" ]; then + warn "goose binary not found in extracted archive. (non-fatal)" + exit 0 +fi +bindir="$(dirname "$goose_bin")" + +mkdir -p "$dest_dir" 2>/dev/null || true + +# Install the goose binary. +if install -m 0755 "$goose_bin" "$dest_dir/goose" 2>/dev/null \ + || { cp "$goose_bin" "$dest_dir/goose" && chmod 0755 "$dest_dir/goose"; }; then + : +else + warn "failed to install goose to $dest_dir. (non-fatal)" + exit 0 +fi + +# Some goose builds ship sidecar shared objects (*.so, temporal worker helpers) +# next to the binary. Copy any co-located non-`goose` regular files into a +# dedicated libdir and point the binary at it via an rpath-style wrapper only if +# they exist — the standard gnu build is a single self-contained binary, so this +# is usually a no-op. We keep it simple: copy siblings into dest_dir (they are +# small helper binaries goose invokes by name off PATH). +for f in "$bindir"/*; do + [ -f "$f" ] || continue + base="$(basename "$f")" + [ "$base" = "goose" ] && continue + case "$base" in + *.so|*.so.*|goose-*|temporal*|*.dylib) : ;; + *) continue ;; + esac + install -m 0755 "$f" "$dest_dir/$base" 2>/dev/null \ + || { cp "$f" "$dest_dir/$base" 2>/dev/null && chmod 0755 "$dest_dir/$base" 2>/dev/null; } \ + || warn "could not install sidecar '$base' (may be unneeded for this build)." +done + +# Confirm the installed binary actually EXECUTES on this arch. +if "$dest_dir/goose" --version >/dev/null 2>&1; then + echo "goose-server(install): installed goose $ver ($rarch/$variant) to $dest_dir/goose" +else + warn "installed goose to $dest_dir but it does not execute (arch mismatch or missing sidecar?)." + warn " guest arch: $uname_m -> $rarch/$variant. (non-fatal)" + exit 0 +fi + +exit 0 diff --git a/integrations/isolation/acq-kits/goose-server/files/home/goose-start.sh b/integrations/isolation/acq-kits/goose-server/files/home/goose-start.sh new file mode 100755 index 00000000..09527761 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/files/home/goose-start.sh @@ -0,0 +1,162 @@ +#!/bin/sh +# goose-start.sh — merge the goose USAi config on first boot, then supervise the +# goosed ACP server (`goose serve`) so it auto-restarts if it exits. +# +# SCOPE: this startup script manages ONE thing — a single supervised +# `goose serve --host 0.0.0.0 --port --platform desktop` (with a shared +# secret from GOOSE_SERVER__SECRET_KEY). The server runs under a respawn loop so a +# crash or a self-update self-heals. Because this script runs as a `startup` +# command — which fires on EVERY sandbox start, including a detached `acq create` +# with nobody attached, held open by the sandbox's tini keepalive (PID 1), +# independent of any interactive session — the goosed server comes up on its own. +# No `acq exec` / no `acq run` is needed to bring it up. A single `acq create` is +# enough; then point goose Desktop at the published host port (see the kit README). +# +# The goose CLI itself is installed at CREATE time (install phase) by +# goose-install.sh — a single pinned, sha-verified static binary. This script only +# assumes `goose` is on PATH; if it is not (a non-fatal install failure), it warns +# and exits 0 so the sandbox is never dead over the optional server. +# +# AUTH: goose Desktop 1.50.0's ACP client ALWAYS performs a secret-key handshake +# (X-Secret-Key on /status, ?token= on /acp), so a server started +# --dangerously-unauthenticated is rejected by Desktop with "server cannot connect" +# even though /status is reachable (confirmed on a live 1.50.0 Desktop). The server +# therefore runs WITH GOOSE_SERVER__SECRET_KEY (from the kit env; the operator +# pastes the same value into Desktop's Secret Key field). This is a cleartext +# loopback HANDSHAKE TOKEN, not a protected credential — it guards only a +# host-loopback port and must also live in Desktop's settings. The SANDBOX remains +# the real security boundary (ephemeral container/microVM, deny-default allow-listed +# egress, no host FS, injected credentials); the published port is HOST LOOPBACK +# ONLY. See docs/decisions/0002-plaintext-http-loopback.md. +# +# IN-GUEST BIND IS 0.0.0.0 (all guest interfaces), NOT 127.0.0.1 — the same lesson +# the openchamber/paseo kits document: acq's create-time port publish (msb +# `-p HOST:GUEST`) binds a HOST loopback listener but the publisher dials the +# sandbox's GUEST NETWORK IP, not guest loopback, so a 127.0.0.1-only bind answers +# `acq exec -- curl 127.0.0.1:` from inside the guest yet the published +# host port returns "Empty reply from server". Binding 0.0.0.0 makes the guest port +# reachable through create-time publishedPorts; the HOST side of the publish stays +# loopback-only, so 0.0.0.0 in-guest does not widen host exposure. +# +# Pins/config arrive via the environment (fallback defaults kept in sync with the +# kit spec): +# GOOSE_SERVE_HOST — bind address (default 0.0.0.0) +# GOOSE_SERVE_PORT — bind port (default 3284, goosed's ACP default) +# GOOSE_RESTART_DELAY — seconds before respawning a stopped server (default 5) + +set -eu + +GOOSE_SERVE_HOST="${GOOSE_SERVE_HOST:-0.0.0.0}" +GOOSE_SERVE_PORT="${GOOSE_SERVE_PORT:-3284}" +RESTART_DELAY="${GOOSE_RESTART_DELAY:-5}" + +warn() { echo "goose-server(startup): $*" >&2; } + +# goose is installed to /usr/local/bin at create time; make sure that is on PATH +# for THIS non-login shell (process-local; other `acq exec sh -c` callers must set +# their own PATH — the verify script does). +case ":$PATH:" in *":/usr/local/bin:"*) : ;; *) PATH="/usr/local/bin:$PATH" ;; esac +export PATH + +STATE_DIR="$HOME/.local/state/goose" +mkdir -p "$STATE_DIR" 2>/dev/null || true + +# --- Merge the USAi config primer into goose's global config (every boot). ------ +# Idempotent + non-fatal (converges to a stable fixed point; see merge-config.mjs). +# goose's config dir is XDG: ~/.config/goose. We stage the primer at +# ~/goose-config/config.yaml (dropped by the kit) and merge it in. +GOOSE_CONFIG_DIR="${GOOSE_CONFIG_DIR:-$HOME/.config/goose}" +SOURCE_CONFIG="$HOME/goose-config/config.yaml" +MERGE_SCRIPT="$HOME/goose-config/merge-config.mjs" +if [ -f "$SOURCE_CONFIG" ] && [ -f "$MERGE_SCRIPT" ] && command -v node >/dev/null 2>&1; then + node "$MERGE_SCRIPT" --source "$SOURCE_CONFIG" --global-dir "$GOOSE_CONFIG_DIR" \ + >>"$STATE_DIR/goose-config-merge.log" 2>&1 \ + || warn "config merge failed (non-fatal); see $STATE_DIR/goose-config-merge.log" +else + warn "config primer or node missing; skipping goose config merge (non-fatal)." +fi + +# --- Stage the custom_usai provider definition into goose's config dir. --------- +# goose Desktop drives the external server by NAMED provider (`custom_usai`). The +# server must define that same provider or agent init fails and Desktop cannot +# connect. The kit ships custom_usai.json (base_url + models, NO secret — goose +# reads the key from the CUSTOM_USAI_API_KEY env acq injects). Copy it into +# ~/.config/goose/custom_providers/ only if it is not already the same bytes, so a +# provider the user later edited in Desktop is not clobbered on every boot. +# Non-fatal: a copy failure warns but never fails the sandbox. +CUSTOM_SRC="$HOME/goose-config/custom_providers/custom_usai.json" +CUSTOM_DEST_DIR="$GOOSE_CONFIG_DIR/custom_providers" +CUSTOM_DEST="$CUSTOM_DEST_DIR/custom_usai.json" +if [ -f "$CUSTOM_SRC" ]; then + if [ ! -f "$CUSTOM_DEST" ] || ! cmp -s "$CUSTOM_SRC" "$CUSTOM_DEST"; then + mkdir -p "$CUSTOM_DEST_DIR" 2>/dev/null || true + if cp "$CUSTOM_SRC" "$CUSTOM_DEST" 2>/dev/null; then + echo "goose-server(startup): staged custom_usai provider to $CUSTOM_DEST" \ + >>"$STATE_DIR/goose-config-merge.log" 2>&1 || true + else + warn "could not stage custom_usai.json to $CUSTOM_DEST (non-fatal)." + fi + fi +else + warn "custom_usai.json primer missing; Desktop's custom_usai provider will be undefined on the server (non-fatal)." +fi + +# --- Require the goose binary. ------------------------------------------------- +if ! command -v goose >/dev/null 2>&1; then + warn "goose binary not found on PATH (install may have failed at create time)." + warn " The goosed server will not start; the sandbox is otherwise unaffected." + warn " See the install log and TROUBLESHOOTING.md." + exit 0 # never fail the sandbox over the optional server +fi + +# --- Supervise `goose serve` (idempotent). ------------------------------------- +# Guard against a second startup run spawning a duplicate supervisor: only start +# one if it isn't already running. The loop carries a marker ARGUMENT +# ("supervisor:goose-serve") that the inner `sh -c` ignores; pgrep -f matches the +# whole command line and finds the loop by that marker. (An argument, not +# `exec -a` — the startup shell is POSIX sh/dash, which has no `exec -a`.) +supervisor_running() { pgrep -u "$(id -u)" -f "supervisor:$1" >/dev/null 2>&1; } + +SERVE_LOG="$STATE_DIR/goose-serve.log" + +# `goose serve` blocks in the foreground, so the supervisor can see it exit and +# relaunch it. Flags: +# --host/--port the bind, published to the host by the kit +# --platform desktop tells goosed it is backing a Desktop client +# (the remote-server guide's recommended flag) +# --enable-scheduler allow scheduled recipes (parity with the +# launchd example in goose's remote-server guide) +# +# AUTH: goose Desktop 1.50.0's ACP client ALWAYS performs a secret-key handshake — +# its health check sends `X-Secret-Key: ` to /status and builds the ACP +# URL as `/acp?token=`. A server started `--dangerously-unauthenticated` +# does not satisfy that contract, so Desktop reports "server cannot connect" even +# though /status is reachable (empirically confirmed on a live 1.50.0 Desktop). +# We therefore run WITH a shared secret from GOOSE_SERVER__SECRET_KEY (set by the +# kit env; the operator pastes the same value into Desktop's Secret Key field). +# goose reads GOOSE_SERVER__SECRET_KEY from the environment, so we do not pass it +# on argv (keeps it out of the process command line / logs). See +# docs/decisions/0002-plaintext-http-loopback.md. +# +# argv to the inner sh -c: $0=marker, $1=RESTART_DELAY, $2=HOST, $3=PORT. +# GOOSE_SERVER__SECRET_KEY is inherited from this script's environment. +if [ -z "${GOOSE_SERVER__SECRET_KEY:-}" ]; then + warn "GOOSE_SERVER__SECRET_KEY is not set; goose Desktop 1.50.0 will not connect." + warn " The kit env should set it. See TROUBLESHOOTING.md." +fi +export GOOSE_SERVER__SECRET_KEY +if ! supervisor_running goose-serve; then + ( sh -c ' + while :; do + echo "[supervisor] starting goose serve at $(date -u +%FT%TZ)" + goose serve \ + --host "$2" \ + --port "$3" \ + --platform desktop \ + --enable-scheduler || true + echo "[supervisor] goose serve exited; restarting in ${1}s" + sleep "$1" + done + ' "supervisor:goose-serve" "$RESTART_DELAY" "$GOOSE_SERVE_HOST" "$GOOSE_SERVE_PORT" \ + >>"$SERVE_LOG" 2>&1 ) & +fi diff --git a/integrations/isolation/acq-kits/goose-server/scripts/verify b/integrations/isolation/acq-kits/goose-server/scripts/verify new file mode 100644 index 00000000..77a96285 --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/scripts/verify @@ -0,0 +1,481 @@ +#!/usr/bin/env bash +# +# verify — host-side validation for the goose-server acq mixin kit. +# +# Two layers: +# +# 1. OFFLINE GATE (always runs): the backend-agnostic validator +# integrations/isolation/acq-kits/validate-kits.py (schema + files[].source + +# registry) plus node --check of the merge helper. Needs python3 with +# jsonschema + pyyaml; if those aren't importable this reports SKIP (with the +# fix), not a false FAIL. +# +# 2. LIVE END-TO-END (opt-in): create a real sandbox with the kit applied, then +# assert the goose CLI install, the startup-supervised goosed server, the +# published port, the in-guest 0.0.0.0 bind (reachable via create-time +# publish) over the host port, the secret-key ACP handshake, the config +# merge (USAi provider), and the respawn supervisor self-heal. +# +# Preferred driver — RUN_ACQ=1: drive the `acq` CLI. acq translates the +# neutral hybrid/v1 spec and applies it, so the kit's files/commands land and +# acq publishes port 3284 at CREATE time from the neutral publishedPorts. +# +# Legacy driver — RUN_SBX=1: drive `sbx` on a PRE-TRANSLATED sbx-v2 kit dir +# (SBX_KIT_DIR). sbx cannot parse the neutral schema; without SBX_KIT_DIR this +# path SKIPs with guidance. Prefer RUN_ACQ=1. +# +# NOTE: this kit rides on the `shell` agent (Increment A). We create the sandbox +# with the `shell` agent token so there is no coding-agent TUI entrypoint — the +# goosed server is brought up entirely by the kit's startup command, held open by +# the sandbox's tini keepalive. (`acq create` is detached, so nothing needs to +# hold PID 1 here — unlike the paseo/openchamber wrappers.) +# +# Usage (from anywhere): +# path/to/goose-server/scripts/verify # offline gate only (default) +# RUN_ACQ=1 path/to/.../scripts/verify # live, via acq (preferred) +# KEEP=1 RUN_ACQ=1 path/to/.../scripts/verify # keep the sandbox to inspect +# RUN_SBX=1 SBX_KIT_DIR= .../verify # live, via sbx (needs sbx-v2 dir) + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REPO_ROOT="$(cd "$KIT_DIR/../../../.." && pwd)" +# Sibling zscaler kit applied alongside so the goose CLI download works on an +# HTTPS-inspected network (its CA lands in the system trust store). +ZSCALER_KIT_DIR="$(cd "$KIT_DIR/../zscaler-ca-certificate" 2>/dev/null && pwd || true)" +SBX_NAME="goose-server-verify-$$" +WORK="$(mktemp -d "$KIT_DIR/verify-workspace.XXXXXX")" +CREATE_LOG="$WORK/create.log" +KEEP="${KEEP:-0}" +RUN_ACQ="${RUN_ACQ:-0}" +RUN_SBX="${RUN_SBX:-0}" +SBX_KIT_DIR="${SBX_KIT_DIR:-}" +# Container port goosed binds (ACP API + WebSocket). Keep in sync with spec. +GOOSE_PORT="${GOOSE_PORT:-3284}" +# How long to wait for the create-time install + background startup. Override with +# READY_TIMEOUT. +READY_TIMEOUT="${READY_TIMEOUT:-90}" +POLL_INTERVAL="${POLL_INTERVAL:-2}" + +pass=0 +fail=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); } +skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; } +warn() { printf ' \033[33mWARN\033[0m %s\n' "$1"; } +info() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +cleanup() { + if [ "$KEEP" = "1" ]; then + printf '\nKEEP=1 set; leaving sandbox %s and %s in place.\n' "$SBX_NAME" "$WORK" >&2 + return + fi + case "${DRIVER:-}" in + acq) acq rm "$SBX_NAME" >/dev/null 2>&1 || true ;; + *) sbx rm -f "$SBX_NAME" >/dev/null 2>&1 || true ;; + esac + rm -rf "$WORK" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +# --- Driver abstraction: exec a command inside the sandbox (non-TTY). -------- +# PATH SCOPING: a bare `sh -c` sources nothing, so prepend /usr/local/bin (where +# goose-install.sh installs the binary) for every probe so `goose` resolves. +in_sbx() { + _wrapped='export PATH="/usr/local/bin:$PATH"; '"$1" + case "${DRIVER:-}" in + acq) acq exec "$SBX_NAME" -- sh -c "$_wrapped" /dev/null || true ;; + *) sbx exec "$SBX_NAME" -- sh -c "$_wrapped" /dev/null || true ;; + esac +} +sbx_ports_out() { + case "${DRIVER:-}" in + acq) acq ports "$SBX_NAME" 2>/dev/null || true ;; + *) sbx ports "$SBX_NAME" 2>/dev/null || true ;; + esac +} + +info "0. Preconditions" +[ -f "$KIT_DIR/spec.yaml" ] && ok "kit spec.yaml present" || { bad "spec.yaml missing"; exit 1; } + +info "1. Validate the neutral kit spec" +VALIDATOR="$REPO_ROOT/integrations/isolation/acq-kits/validate-kits.py" +if ! command -v python3 >/dev/null 2>&1; then + skip "validate-kits.py not run (python3 not on PATH)" +elif [ ! -f "$VALIDATOR" ]; then + skip "validate-kits.py not found at $VALIDATOR" +elif ! python3 -c 'import jsonschema, yaml' >/dev/null 2>&1; then + skip "validate-kits.py needs 'jsonschema' + 'pyyaml' — install them (pip install jsonschema pyyaml) and re-run" +else + if val_out="$(python3 "$VALIDATOR" --root "$REPO_ROOT" 2>&1)"; then + ok "acq-kits validate-kits.py passes (schema + registry)" + else + bad "validate-kits.py failed:" + printf '%s\n' "$val_out" | sed 's/^/ | /' >&2 + fi +fi + +# --- Offline check of the config-merge helper (no sandbox needed). ------------ +info "1b. config-merge helper parses + is idempotent (offline)" +MERGE="$KIT_DIR/files/home/goose-config/merge-config.mjs" +SRC_CONF="$KIT_DIR/files/home/goose-config/config.yaml" +if ! command -v node >/dev/null 2>&1; then + skip "node not on PATH — cannot exercise merge-config.mjs" +elif [ ! -f "$MERGE" ]; then + bad "merge-config.mjs missing at $MERGE" +elif ! node --check "$MERGE" >/dev/null 2>&1; then + bad "merge-config.mjs failed node --check" +else + ok "merge-config.mjs parses (node --check)" + # First merge into an empty dir copies verbatim; second re-reads + normalizes. + # Third must be byte-identical to the second (stable fixed point). + _gdir="$WORK/goose-cfg"; mkdir -p "$_gdir" + node "$MERGE" --source "$SRC_CONF" --global-dir "$_gdir" >/dev/null 2>&1 + node "$MERGE" --source "$SRC_CONF" --global-dir "$_gdir" >/dev/null 2>&1 + _after2="$(cat "$_gdir/config.yaml" 2>/dev/null)" + node "$MERGE" --source "$SRC_CONF" --global-dir "$_gdir" >/dev/null 2>&1 + _after3="$(cat "$_gdir/config.yaml" 2>/dev/null)" + if [ -n "$_after2" ] && [ "$_after2" = "$_after3" ]; then + ok "merge converges to a stable fixed point (2nd == 3rd write)" + else + bad "merge did not converge (2nd and 3rd write differ)" + fi + # The merged config must select the custom_usai provider. + if printf '%s\n' "$_after3" | grep -Eq '^active_provider:[[:space:]]*custom_usai'; then + ok "merged goose config selects the custom_usai (USAi) provider" + else + bad "merged goose config does not set active_provider: custom_usai" + fi +fi + +# --- Offline: the shipped custom_usai provider definition is valid JSON. -------- +info "1c. custom_usai provider definition is valid + names CUSTOM_USAI_API_KEY (offline)" +CUSTOM="$KIT_DIR/files/home/goose-config/custom_providers/custom_usai.json" +if [ ! -f "$CUSTOM" ]; then + bad "custom_usai.json missing at $CUSTOM" +elif ! command -v node >/dev/null 2>&1; then + skip "node not on PATH — cannot validate custom_usai.json" +else + _cj="$(node -e 'const j=require(process.argv[1]); process.stdout.write([j.name,j.engine,j.api_key_env,j.base_url].join("|"))' "$CUSTOM" 2>/dev/null)" + case "$_cj" in + "custom_usai|openai|CUSTOM_USAI_API_KEY|https://api.gsa.usai.gov/api/v1/chat/completions") + ok "custom_usai.json is valid JSON with the expected name/engine/api_key_env/base_url" ;; + "") + bad "custom_usai.json is not valid JSON (node could not parse it)" ;; + *) + bad "custom_usai.json fields unexpected: '$_cj' (want custom_usai|openai|CUSTOM_USAI_API_KEY|https://api.gsa.usai.gov/api/v1/chat/completions)" ;; + esac +fi + +# --- Select the live driver. ------------------------------------------------- +DRIVER="" +if [ "$RUN_ACQ" = "1" ]; then + DRIVER="acq" +elif [ "$RUN_SBX" = "1" ]; then + DRIVER="sbx" +fi + +if [ -z "$DRIVER" ]; then + info "Live sandbox checks (2-8): SKIPPED" + echo " Set RUN_ACQ=1 to run the live end-to-end check via acq (preferred)." >&2 + echo " (RUN_SBX=1 also works but needs a pre-translated sbx-v2 kit; see header.)" >&2 + info "Summary" + printf ' %d passed, %d failed\n' "$pass" "$fail" + [ "$fail" -eq 0 ] && printf ' \033[32mOffline gate passed.\033[0m\n' || printf ' \033[31mOffline gate failed — see above.\033[0m\n' + exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" +fi + +if ! command -v "$DRIVER" >/dev/null 2>&1; then + # NB: portable uppercase (tr), NOT ${DRIVER^^} — macOS ships bash 3.2, which + # lacks the ^^ expansion and would abort this line with "bad substitution". + echo " RUN_$(printf '%s' "$DRIVER" | tr '[:lower:]' '[:upper:]')=1 set but '$DRIVER' not on PATH. Install it and retry." >&2 + exit 1 +fi +echo " driver: $DRIVER" + +# --- HOST-PORT CONTENTION PRECHECK (before create). -------------------------- +# Same rationale as the paseo kit: on the msb backend create-time publish can map +# guest 3284 -> host 3284 deterministically, so a pre-existing listener on the +# host port (another sandbox's msb forwarder, or a local `goose serve`) would make +# a HOST fetch hit the OTHER owner — a false FAIL. Record it so the host-fetch +# step can SKIP with accurate guidance rather than FAIL. +host_port_collision="" +if command -v lsof >/dev/null 2>&1; then + precheck_owner="$(lsof -nP -iTCP:"$GOOSE_PORT" -sTCP:LISTEN 2>/dev/null | awk 'NR>1{print $1" (pid "$2")"}' | sort -u | tr '\n' ',' | sed 's/,$//')" + if [ -n "$precheck_owner" ]; then + host_port_collision="$precheck_owner" + echo " NOTE: host port $GOOSE_PORT is already in use before create by: $precheck_owner" >&2 + echo " (commonly another acq/msb sandbox's 'msb' forwarder, or a local 'goose serve')." >&2 + echo " The create-time host mapping may collide; the host-reachability step will SKIP." >&2 + fi +fi + +info "2. Create a sandbox with the kit (shell agent)" +if [ "$DRIVER" = "acq" ]; then + echo " creating $SBX_NAME via acq (extra kit: $KIT_DIR)..." + # Redirect stdin from /dev/null: `acq create` may prompt; feed it EOF. + if ACQ_EXTRA_KITS="$KIT_DIR" acq create --name "$SBX_NAME" shell \ + "$WORK" "$CREATE_LOG" 2>&1; then + ok "sandbox created via acq (kit translated + applied)" + else + bad "acq create failed (exit $?)" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 + fi + info "2b. Container port $GOOSE_PORT published at create (neutral publishedPorts)" + PORTS_PUBLISH_TIMEOUT="${PORTS_PUBLISH_TIMEOUT:-15}" + published="" + pub_deadline=$(( $(date +%s) + PORTS_PUBLISH_TIMEOUT )) + while :; do + if sbx_ports_out | grep -q "$GOOSE_PORT"; then published=yes; break; fi + [ "$(date +%s)" -ge "$pub_deadline" ] && break + sleep 1 + done + if [ -n "$published" ]; then + ok "container $GOOSE_PORT auto-published at create" + else + warn "container $GOOSE_PORT not auto-published after ${PORTS_PUBLISH_TIMEOUT}s — older acq? falling back to manual publish" + if acq ports "$SBX_NAME" --publish "${GOOSE_PORT}:${GOOSE_PORT}" >/dev/null 2>&1; then + ok "container $GOOSE_PORT published via fallback (acq ports --publish)" + else + bad "failed to publish container $GOOSE_PORT (acq ports --publish)" + fi + fi +else + if [ -z "$SBX_KIT_DIR" ]; then + info "Live sandbox checks (2-8): SKIPPED" + echo " RUN_SBX=1 needs a PRE-TRANSLATED sbx-v2 kit dir in SBX_KIT_DIR." >&2 + echo " sbx cannot parse the neutral hybrid/v1 spec directly. Prefer RUN_ACQ=1." >&2 + info "Summary" + printf ' %d passed, %d failed\n' "$pass" "$fail" + [ "$fail" -eq 0 ] && printf ' \033[32mOffline gate passed; live checks skipped.\033[0m\n' \ + || printf ' \033[31mOffline gate failed — see above.\033[0m\n' + exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" + fi + create_kits=(--kit "$SBX_KIT_DIR") + if [ -n "$ZSCALER_KIT_DIR" ] && [ -f "$ZSCALER_KIT_DIR/spec.yaml" ]; then + create_kits+=(--kit "$ZSCALER_KIT_DIR") + echo " pairing with zscaler-ca-certificate kit at $ZSCALER_KIT_DIR" + else + echo " NOTE: zscaler-ca-certificate kit not found next to this kit. On an" >&2 + echo " HTTPS-inspected network the goose CLI download may fail TLS." >&2 + fi + echo " creating $SBX_NAME (workspace $WORK) from translated kit $SBX_KIT_DIR..." + dbg=(); [ "${DEBUG:-0}" = "1" ] && dbg=(-D) + if sbx ${dbg[@]+"${dbg[@]}"} create --name "$SBX_NAME" "${create_kits[@]}" shell \ + "$WORK" "$CREATE_LOG" 2>&1; then + ok "sandbox created with --kit" + else + bad "sbx create failed (exit $?)" + sed 's/^/ | /' "$CREATE_LOG" >&2 + exit 1 + fi +fi + +info "3. goose CLI installed at create time + goosed answers (up to ${READY_TIMEOUT}s)" +deadline=$(( $(date +%s) + READY_TIMEOUT )) +installed=""; server_up="" +# goosed's /status endpoint is the liveness probe. With GOOSE_SERVER__SECRET_KEY +# set, /status may require the X-Secret-Key header, so probe WITH the kit secret +# (read from the guest env) and accept a 200. Falls back to a bare probe if the +# secret is somehow unset, so the loop still detects a running server. +status_probe='s="${GOOSE_SERVER__SECRET_KEY:-}"; curl -fsS -H "X-Secret-Key: $s" http://127.0.0.1:'"$GOOSE_PORT"'/status >/dev/null 2>&1 && echo yes || (curl -fsS http://127.0.0.1:'"$GOOSE_PORT"'/status >/dev/null 2>&1 && echo yes)' +while :; do + [ -z "$installed" ] && [ -n "$(in_sbx 'command -v goose')" ] && installed=yes + [ -z "$server_up" ] && [ "$(in_sbx "$status_probe")" = "yes" ] && server_up=yes + [ -n "$installed" ] && [ -n "$server_up" ] && break + [ "$(date +%s)" -ge "$deadline" ] && break + sleep "$POLL_INTERVAL" +done +if [ -n "$installed" ]; then + ok "goose CLI installed (create-time install phase)" + goose_ver="$(in_sbx 'goose --version 2>/dev/null | head -1')" + [ -n "$goose_ver" ] && echo " | $goose_ver" +else + bad "goose CLI not installed — see the install phase output" + in_sbx 'tail -n 25 ~/.local/state/goose/goose-serve.log 2>/dev/null' | sed 's/^/ | /' +fi + +info "4. Config merge applied + custom_usai provider staged" +merged_cfg="$(in_sbx 'cat ~/.config/goose/config.yaml 2>/dev/null')" +if printf '%s\n' "$merged_cfg" | grep -Eq '^active_provider:[[:space:]]*custom_usai'; then + ok "goose global config selects the custom_usai (USAi) provider" +elif [ -n "$merged_cfg" ]; then + warn "goose global config exists but does not set active_provider: custom_usai — merge may have skipped an unmodeled shape" + printf '%s\n' "$merged_cfg" | sed 's/^/ | /' +else + bad "no goose global config found (~/.config/goose/config.yaml) — merge did not run" + in_sbx 'tail -n 20 ~/.local/state/goose/goose-config-merge.log 2>/dev/null' | sed 's/^/ | /' +fi +# The server must DEFINE the custom_usai provider (staged by the startup script) +# or Desktop cannot connect using it. Assert the file landed with the right shape. +custom_dest="$(in_sbx 'cat ~/.config/goose/custom_providers/custom_usai.json 2>/dev/null')" +if printf '%s' "$custom_dest" | grep -q '"name": *"custom_usai"' \ + && printf '%s' "$custom_dest" | grep -q '"api_key_env": *"CUSTOM_USAI_API_KEY"'; then + ok "custom_usai provider definition staged in ~/.config/goose/custom_providers/" +else + bad "custom_usai.json not staged in the guest (~/.config/goose/custom_providers/) — Desktop's custom_usai provider would be undefined on the server" + in_sbx 'ls -la ~/.config/goose/custom_providers/ 2>&1' | sed 's/^/ | /' +fi +# The API key is injected under the provider's api_key_env name. Confirm it is +# present in the guest env (value is a proxied placeholder — SET vs UNSET only). +if [ "$(in_sbx 'test -n "$CUSTOM_USAI_API_KEY" && echo SET || echo UNSET')" = "SET" ]; then + ok "CUSTOM_USAI_API_KEY is present in the guest env (acq-injected)" +else + warn "CUSTOM_USAI_API_KEY is UNSET in the guest — model calls will fail. Store it: acq secret set -g goose-usai --host api.gsa.usai.gov --env CUSTOM_USAI_API_KEY" +fi + +info "5. goosed server (started by the startup supervisor, on its own)" +if [ -n "$server_up" ]; then + ok "goosed answers /status on 127.0.0.1:$GOOSE_PORT (started by the startup supervisor)" +else + bad "goosed did not answer /status on :$GOOSE_PORT (see the serve log)" + in_sbx 'tail -n 25 ~/.local/state/goose/goose-serve.log 2>/dev/null' | sed 's/^/ | /' +fi + +info "5b. Secret-key ACP handshake (what goose Desktop 1.50.0 requires)" +# Desktop's ACP client sends X-Secret-Key to /status and ?token= to /acp. +# The kit sets GOOSE_SERVER__SECRET_KEY. Assert the server ACCEPTS the kit's secret +# and REJECTS a wrong one, so a Desktop configured with the same secret connects. +if [ -n "$server_up" ]; then + # The secret the guest server was started with (from the kit env). + kit_secret="$(in_sbx 'printf %s "${GOOSE_SERVER__SECRET_KEY:-}"')" + if [ -z "$kit_secret" ]; then + bad "GOOSE_SERVER__SECRET_KEY is empty in the guest — Desktop 1.50.0 cannot connect" + else + ok "GOOSE_SERVER__SECRET_KEY is set in the guest env" + # /status WITH the correct secret must be 200. + good_code="$(in_sbx "curl -fsS -o /dev/null -w '%{http_code}' -H 'X-Secret-Key: $kit_secret' http://127.0.0.1:$GOOSE_PORT/status 2>/dev/null")" + if [ "$good_code" = "200" ]; then + ok "/status returns 200 with the kit secret (handshake accepted)" + else + warn "/status with the kit secret returned ${good_code:-?} (expected 200)" + fi + # /acp with the correct secret but no SSE header must be 406 (auth passed, + # missing text/event-stream) — NOT 401/403 (which would mean secret rejected). + acp_code="$(in_sbx "curl -fsS -o /dev/null -w '%{http_code}' 'http://127.0.0.1:$GOOSE_PORT/acp?token=$kit_secret' 2>/dev/null")" + case "$acp_code" in + 406) ok "/acp?token= returns 406 (auth passed; ACP endpoint live)" ;; + 401|403) bad "/acp with the kit secret returned $acp_code (secret rejected — Desktop would fail auth)" ;; + *) warn "/acp with the kit secret returned ${acp_code:-?} (expected 406)" ;; + esac + fi +else + skip "secret-key handshake check (server not up)" +fi + +info "6. Host port published + reachable over the create-time publish" +ports_out="$(sbx_ports_out)" +if printf '%s' "$ports_out" | grep -q "$GOOSE_PORT"; then + ok "ports maps container $GOOSE_PORT to a host port" +else + bad "no host mapping for container $GOOSE_PORT in 'ports' output" +fi +printf '%s\n' "$ports_out" | sed 's/^/ | /' + +HOST_HP="$(printf '%s' "$ports_out" | grep -Eo '127\.0\.0\.1:[0-9]+' | head -1)" +host_fetch_ok="" +if [ -z "$server_up" ]; then + skip "host reachability (server not up)" +elif ! command -v curl >/dev/null 2>&1; then + skip "host reachability (no curl on the HOST)" +elif [ -z "$HOST_HP" ]; then + skip "host reachability (could not resolve a 127.0.0.1: host mapping)" +elif [ -n "$host_port_collision" ] && [ "${HOST_HP##*:}" = "$GOOSE_PORT" ]; then + skip "host reachability (host port $GOOSE_PORT was already in use BEFORE create by: $host_port_collision — this sandbox's create-time -p collides; a HOST fetch would hit the OTHER owner. Stop it and re-run. Do NOT substitute 'acq ports --publish': its ssh -L tunnel dials guest loopback and would not exercise the guest-IP transport this step verifies)." +else + # Send the kit secret (Desktop does), matching how /status is guarded. + kit_secret_host="$(in_sbx 'printf %s "${GOOSE_SERVER__SECRET_KEY:-}"')" + h_code="$(curl -fsS -o /dev/null -w '%{http_code}' -H "X-Secret-Key: $kit_secret_host" "http://$HOST_HP/status" 2>/dev/null)"; h_rc=$? + if [ "$h_rc" -eq 0 ] && [ "$h_code" = "200" ]; then + ok "goosed /status reachable over the published host port ($HOST_HP) with the kit secret" + host_fetch_ok=yes + else + bad "goosed /status NOT reachable over $HOST_HP (rc=$h_rc code=${h_code:-?}). This is a genuine host->guest transport problem." + fi +fi + +info "6b. Guest server bind is 0.0.0.0 (reachable via create-time publish), not loopback" +# goosed must bind 0.0.0.0:3284 in-guest so acq's create-time publish reaches it +# (same msb transport lesson as the paseo/openchamber kits: -p dials the guest +# network IP, not guest loopback). Read the LISTEN address from /proc/net/tcp +# (hex) so we don't depend on ss/netstat. 0100007F=127.0.0.1; 00000000=0.0.0.0. +if [ -z "$server_up" ]; then + skip "in-guest bind check (server not up)" +else + bind_addr="$(in_sbx ' + p=$(printf "%04X" '"$GOOSE_PORT"') + if command -v ss >/dev/null 2>&1; then + ss -ltnH 2>/dev/null | awk -v port='"$GOOSE_PORT"' "\$4 ~ (\":\" port \"\$\") {print \$4}" | head -1 + else + awk -v P="$p" "\$2 ~ (\":\" P \"\$\") && \$4==\"0A\" {print \$2}" /proc/net/tcp 2>/dev/null | head -1 + fi')" + # Port 3284 = 0x0CD4. + case "$bind_addr" in + 0.0.0.0:*|*00000000:0CD4*) + ok "goosed LISTEN socket bound to 0.0.0.0 in-guest ($bind_addr) — reachable via create-time publish" ;; + 127.0.0.1:*|*0100007F:0CD4*) + bad "goosed bound to loopback in-guest ($bind_addr) — create-time publish returns 'Empty reply from server' on msb (GOOSE_SERVE_HOST?; see quickstart#333)" ;; + "") + warn "could not read goosed's LISTEN address (no ss + unreadable /proc/net/tcp); relying on the host-port fetch as the reachability proof" ;; + *) + warn "unexpected goosed LISTEN address '$bind_addr' (expected 0.0.0.0:$GOOSE_PORT)" ;; + esac + if [ -n "$host_fetch_ok" ]; then + ok "reachable over the published host port (proven by step 6 fetch)" + elif [ -n "$host_port_collision" ] && [ "${HOST_HP##*:}" = "$GOOSE_PORT" ]; then + skip "host-port reachability (step 6 skipped due to host port $GOOSE_PORT collision with $host_port_collision — bind is confirmed 0.0.0.0 above)" + else + skip "host-port reachability (step 6 did not complete a successful host fetch — bind is confirmed 0.0.0.0)" + fi +fi + +info "7. Respawn supervisor running (auto-restart) — the goosed server" +sup="$(in_sbx 'pgrep -c -f "supervisor:goose[-]serve" 2>/dev/null')" +case "${sup:-0}" in + 1) ok "goose-serve supervisor running (exactly one)" ;; + 0) bad "no goose-serve supervisor found (server auto-restart not active)" ;; + *) bad "found ${sup} goose-serve supervisors (expected 1 — duplicate respawner?)" ;; +esac + +info "8. Server self-heals after being killed" +if [ -z "$server_up" ]; then + skip "self-heal guard: server was not up to kill" +else + sup_before="$(in_sbx 'pgrep -u "$(id -u)" -f "supervisor:goose-serve" | sort | tr "\n" " "')" + # Kill ONLY the `goose serve` process tree, NOT the supervisor loop (whose argv + # carries the marker). Match "goose serve" but exclude the supervisor marker. + in_sbx ' + for pid in $(pgrep -u "$(id -u)" -f "goose serve"); do + if tr "\0" " " /dev/null | grep -q "supervisor:goose-serve"; then + continue + fi + kill "$pid" 2>/dev/null + done + echo killed + ' >/dev/null + sup_after="$(in_sbx 'pgrep -u "$(id -u)" -f "supervisor:goose-serve" | sort | tr "\n" " "')" + if [ -z "$sup_after" ]; then + bad "the goose-serve SUPERVISOR was killed by the probe — kill was not precise enough" + else + ok "goose-serve supervisor survived the kill (before='$sup_before' after='$sup_after')" + fi + healed="" + heal_deadline=$(( $(date +%s) + 45 )) # RESTART_DELAY (5s) + server readiness + while :; do + [ "$(in_sbx "$status_probe")" = "yes" ] && { healed=yes; break; } + [ "$(date +%s)" -ge "$heal_deadline" ] && break + sleep "$POLL_INTERVAL" + done + if [ -n "$healed" ]; then + ok "goosed was re-launched after being killed (supervisor:goose-serve self-healed)" + else + bad "goosed did NOT come back after being killed — supervisor:goose-serve not respawning" + in_sbx 'tail -n 20 ~/.local/state/goose/goose-serve.log 2>/dev/null' | sed 's/^/ | /' + fi +fi + +info "Summary" +[ "$fail" -eq 0 ] && printf ' \033[32mAll checks passed.\033[0m\n' || printf ' \033[31mSome checks failed — see notes above.\033[0m\n' + +[ "$fail" -eq 0 ] diff --git a/integrations/isolation/acq-kits/goose-server/spec.yaml b/integrations/isolation/acq-kits/goose-server/spec.yaml new file mode 100644 index 00000000..dfcd4edc --- /dev/null +++ b/integrations/isolation/acq-kits/goose-server/spec.yaml @@ -0,0 +1,310 @@ +# spec.yaml — goose-server (a neutral acq mixin kit, hybrid/v1) +# +# Runs `goose serve` (the "goosed" ACP server — Block/AAIF goose's backend) INSIDE +# the sandbox and publishes its port to the host, so a goose DESKTOP app running +# NATIVELY on the host can drive it as an EXTERNAL server. All the agent work — the +# interactive loop, tool execution, MCP extensions, file operations — happens in +# the sandbox, where acq's controls apply (ephemeral container/microVM, deny-default +# allow-listed egress, no host FS, injected credentials). The desktop app is a thin +# UI; the agent's blast radius stays inside the sandbox. This is goose's own +# documented "remote goose server" pattern (goose Desktop → Settings → goose Server +# → external server), NOT a workaround. It is the FIRST acq "chat harness" kit — +# structurally like the openchamber/paseo kits (supervise a long-lived server in the +# guest, publish one host-loopback port), except the client is a native desktop app +# rather than a browser served from the guest. See +# docs/decisions/0001-goosed-in-sandbox-desktop-on-host.md. +# +# OPT-IN: not a default GSA kit. Apply it with an extra kit ref (see README.md +# "Quick start"). +# +# NEUTRAL (backend-agnostic) hybrid/v1 form, structured like the sibling openchamber +# and paseo kits. acq's kit-translate layer maps the declarative +# caps/files/commands/publishedPorts/environment below onto whichever isolation +# backend acq has active (sbx or msb); this kit carries no backend-specific config. +# +# DESIGN (see docs/decisions/): +# - The goose CLI is installed at CREATE time (install phase, root) by +# goose-install.sh — a SINGLE pinned, sha256-verified static binary from GitHub +# Releases (no npm, no native build, no toolchain). Unlike openchamber/paseo +# (which install npm packages at startup to keep a flaky native build off the +# create path), goose's single-binary install is deterministic, so create-time +# is appropriate — and it is still fully NON-FATAL (a transient download failure +# degrades to "server absent", never a dead sandbox). +# - The STARTUP script (goose-start.sh, agent uid, background) owns the server: on +# every sandbox start it (1) merges the USAi config primer into goose's global +# config (idempotent, converges), then (2) supervises a single +# `goose serve --host 0.0.0.0 --port 3284 --platform desktop` (with +# GOOSE_SERVER__SECRET_KEY from the env) under a respawn loop. Because a +# `startup` command fires on EVERY start — including a detached `acq create` +# with nobody attached, held open by the sandbox's tini keepalive (PID 1) — the +# server comes up on its own. A single `acq create` is enough; no `acq exec`/ +# `acq run` needed. +# +# PROVIDER (USAi): the kit ships a goose CUSTOM provider named `custom_usai` (an +# OpenAI-compatible custom provider — files/home/goose-config/custom_providers/ +# custom_usai.json), pointed at the GSA USAi gateway +# (base_url https://api.gsa.usai.gov/api/v1/chat/completions). We use a NAMED custom +# provider — NOT the built-in `openai` provider — because goose Desktop drives the +# external server by named provider + model, and a real GSA Desktop is configured +# with `custom_usai`. If the server defined a different provider, Desktop's session +# would fail agent init and could not connect (learned from a live Desktop test). +# The API KEY is the only secret; goose reads it from the env var named in the +# provider's api_key_env: CUSTOM_USAI_API_KEY. acq injects it via a CUSTOM secret +# service bound to api.gsa.usai.gov — NEVER stored in this kit. Register it once +# (verified against the acq secret store: a custom service's --env name is honored +# end-to-end and the value is swapped only on the wire to its host): +# acq secret set -g goose-usai --host api.gsa.usai.gov --env CUSTOM_USAI_API_KEY +# See docs/decisions/0003-usai-openai-compatible-provider.md. +# +# AUTH: goose Desktop 1.50.0's ACP client ALWAYS performs a secret-key handshake +# (it sends `X-Secret-Key` to /status and `/acp?token=`), so a server run +# `--dangerously-unauthenticated` is rejected by Desktop with "server cannot +# connect" even though /status is reachable (confirmed on a live 1.50.0 Desktop). +# The kit therefore runs WITH a shared secret from GOOSE_SERVER__SECRET_KEY (set in +# the non-secret `environment:` block below; the operator pastes the SAME value +# into Desktop's Secret Key field). This is a cleartext HANDSHAKE TOKEN, not a +# protected credential: it guards only a host-loopback port, and it must also live +# in Desktop's settings, so it is not something acq's secret path could meaningfully +# protect (and acq cannot inject a host-less secret anyway). The sandbox remains the +# real security boundary. TLS + cert-fingerprint pinning is a tracked follow-up. +# See docs/decisions/0002-plaintext-http-loopback.md. +# +# PORT FORWARDING: `goose serve` binds container port 3284 (goosed's ACP default — +# API over HTTP + WebSocket). It is published to the host via the NEUTRAL top-level +# `publishedPorts:` list; acq's translator/adapter maps it to the active backend's +# native port-publish primitive, so the kit needs no per-backend extras. +# +# YAML ALIAS TRAP (documented by the paseo/openchamber kits): acq's kit translation +# emits network allow entries UNQUOTED, and a bare leading-`*` host is parsed by YAML +# as an alias, breaking the translated spec. NO entry below begins with a `*`. +schemaVersion: "hybrid/v1" +kind: mixin +name: goose-server +displayName: goose Server (goosed for goose Desktop) +description: > + Runs the goose ACP server (`goose serve`, "goosed") inside the sandbox and + publishes its port to the host so a natively-installed goose Desktop app can drive + it as an external server. Installs a pinned, sha256-verified goose CLI at create + time and supervises the server on 0.0.0.0:3284 on every start. Ships a goose + custom provider (custom_usai) pointed at the GSA USAi gateway so a goose Desktop + configured with that provider can drive the server (key injected by acq as + CUSTOM_USAI_API_KEY, never stored in the kit). Uses a shared secret-key + handshake (GOOSE_SERVER__SECRET_KEY) that goose Desktop 1.50.0 requires, over a + host-loopback port; the sandbox is the security boundary. Opt-in chat-harness + mixin; composes with the other GSA kits. + +caps: + network: + allow: + # 1. The goose CLI is fetched from GitHub Releases at create time. The tarball + # is served from github.com and redirects to the release-asset object host. + - github.com + - objects.githubusercontent.com + - release-assets.githubusercontent.com + # 2. api.github.com is used only as a fallback for release metadata if the + # pinned direct-asset download needs the latest-tag lookup (goose's own + # download flow does this; our installer pins a version, so this is a + # belt-and-suspenders allowance). + - api.github.com + # 3. The LLM provider endpoint: the GSA USAi OpenAI-compatible gateway. goosed + # calls this for every model turn. The USAi API key is injected on the wire + # to THIS host as CUSTOM_USAI_API_KEY (see the provider note in the header). + - api.gsa.usai.gov + +files: + # Install script (create-time, root). Fetches + verifies + installs the pinned + # goose CLI binary. Mode 0755. + - path: /home/agent/goose-install.sh + mode: "0755" + source: files/home/goose-install.sh + description: Install the pinned, sha256-verified goose CLI from GitHub Releases (create-time, root). + # Startup script (every start, agent uid, background). Merges the USAi config + # primer, then supervises `goose serve`. Mode 0755. + - path: /home/agent/goose-start.sh + mode: "0755" + source: files/home/goose-start.sh + description: Merge the USAi config primer, then install/supervise the goosed server (agent uid, background). + # The goose config primer (USAi provider defaults). Staged; merged into goose's + # global config by merge-config.mjs at startup. Mode 0644 (read-only data). + - path: /home/agent/goose-config/config.yaml + mode: "0644" + source: files/home/goose-config/config.yaml + description: goose USAi config primer (staged; merged into ~/.config/goose/config.yaml at startup). + # JSON/YAML-safe merge helper: lands the primer into goose's global config without + # clobbering an existing config. Node (base image ships node). Mode 0755. + - path: /home/agent/goose-config/merge-config.mjs + mode: "0755" + source: files/home/goose-config/merge-config.mjs + description: Merge the goose USAi primer into ~/.config/goose/config.yaml (fail-safe, idempotent). + # The custom_usai provider definition (base_url + models, NO secret). Staged; + # copied into ~/.config/goose/custom_providers/ at startup so the server defines + # the SAME named provider goose Desktop uses. Mode 0644 (read-only data). + - path: /home/agent/goose-config/custom_providers/custom_usai.json + mode: "0644" + source: files/home/goose-config/custom_providers/custom_usai.json + description: goose custom_usai provider definition (staged; copied into ~/.config/goose/custom_providers/ at startup). + +commands: + # INSTALL phase — create-time, once, as root (uid "0"). Installs the goose CLI. + # The version pin + BOTH arch sha256 hashes are exported here (the script also + # carries matching fallback defaults); bump the version and BOTH hashes together + # to adopt a newer release, re-verifying against the GitHub release-asset digests + # (the release API exposes each asset's `digest: sha256:...`). Pinned to goose + # v1.50.0; hashes are the release-asset digests for the -gnu (glibc) Linux GZIP + # builds (`.tar.gz`). We use GZIP, not bzip2: the minimal shell-docker + # sandbox-template has no `bzip2` binary, so `tar xjf` on a `.tar.bz2` fails there + # (download + sha verify succeed, extraction fails, goose ends up uninstalled) — + # gzip needs only zlib, which is always present. The `.tar.gz` sha256 differs from + # the `.tar.bz2` sha256; these are the GZIP digests. + - phase: install + user: "0" + description: Install the pinned goose CLI binary. + command: + - sh + - -c + - | + GOOSE_VERSION="v1.50.0" \ + GOOSE_LINUX_VARIANT="gnu" \ + GOOSE_SHA256_LINUX_X86_64="6389eea4440178de006fa148d466ac411021315ff7f72b1014beae2d445851e2" \ + GOOSE_SHA256_LINUX_AARCH64="febd71a6a25c3aff7dbcf566f78d2864e87d886c33c4ef1fee2f67fedd334063" \ + sh /home/agent/goose-install.sh + # STARTUP phase — every start, as the agent user (uid "1000"), BACKGROUND. Merges + # the config primer, then supervises `goose serve`. The never-exiting supervisor + # loop must not block sandbox start, so this is marked neutral `background: true` + # — acq's translator/adapter maps it to each backend's native detached hook. The + # `sh -c` body ALSO ends the launch with a trailing `&` as belt-and-suspenders, + # matching the openchamber/paseo shape (the two mechanisms compose safely: the + # backend runs the whole command detached, and the inner `&` returns the short + # `sh -c` immediately after spawning the script). See docs/decisions/. + - phase: startup + # Schema-required: the hybrid/v1 `user` field pattern is ^[0-9]+$ (a numeric + # uid, NOT a name), so this MUST stay "1000" even though the agent uid is + # assigned at provision and is not necessarily 1000. Do not "fix" this to + # `agent`; acq/the backend runs the startup phase as the agent user. + user: "1000" + background: true + description: Merge the USAi config primer, then supervise the goosed server (background). + command: + - sh + - -c + - | + GOOSE_SERVE_HOST="0.0.0.0" \ + GOOSE_SERVE_PORT="3284" \ + sh /home/agent/goose-start.sh & + +# Neutral published port (schema hybrid/v1). Publish the single container/guest port +# goosed binds: 3284 (ACP API + WebSocket). Uses the NEUTRAL shape — `guest:`, +# `protocol:`, `name:`. Host is omitted, so acq defaults the host port to the guest +# value (or an ephemeral host loopback port, backend's choice). acq's translator +# maps this to the active backend's native port-publish primitive, so the port is +# published at create time on both backends with no manual step. +publishedPorts: + - guest: 3284 + protocol: tcp + name: goosed + +# Non-secret guest env. NONE of these are secrets — the API KEY is injected +# separately by acq as CUSTOM_USAI_API_KEY (custom secret service bound to +# api.gsa.usai.gov; see the header), which is the env var the shipped custom_usai +# provider's `api_key_env` names. The provider CONNECTION (base_url + models) lives +# in the staged custom_usai.json, and the active provider + default model live in +# the config primer, so the guest env only needs the model default + server bind. +# +# GOOSE_MODEL default model id (keep in sync with the config primer +# and with a model id present in custom_usai.json) +# GOOSE_SERVE_HOST/PORT the goosed bind (0.0.0.0:3284), read by goose-start.sh +# and kept in sync with publishedPorts above +# GOOSE_DISABLE_SESSION_NAMING=1 avoid a background model call just to name +# sessions (tidy for a server-backed Desktop) +# +# NOTE: no OPENAI_* / GOOSE_PROVIDER env — those select goose's BUILT-IN openai +# provider, which is a different provider identity than the `custom_usai` Desktop +# uses. The custom provider is defined by the shipped custom_usai.json instead. +# +# IN-GUEST BIND IS 0.0.0.0:3284 (all guest interfaces), NOT 127.0.0.1 — the +# openchamber/paseo lesson: acq's create-time port publish (msb `-p HOST:GUEST`) +# binds a HOST loopback listener but dials the sandbox's GUEST NETWORK IP for GUEST, +# not guest 127.0.0.1. So a 127.0.0.1-only bind answers an in-guest curl yet the +# published host port returns "Empty reply from server". Binding 0.0.0.0 makes the +# guest port reachable from the host-published loopback port; the HOST side of the +# publish stays loopback-only, so 0.0.0.0 in-guest does not widen host exposure. +environment: + GOOSE_MODEL: "claude-sonnet-5" + GOOSE_SERVE_HOST: "0.0.0.0" + GOOSE_SERVE_PORT: "3284" + GOOSE_DISABLE_SESSION_NAMING: "1" + # ACP shared secret. goose Desktop 1.50.0's ACP client ALWAYS sends a secret + # (X-Secret-Key on /status, ?token= on /acp), so an unauthenticated server is + # rejected with "server cannot connect" even though /status is reachable + # (confirmed on a live Desktop). goose reads this from the env. It is NOT a real + # credential — it guards only a host-loopback port, and the operator must paste + # the SAME value into goose Desktop → Settings → goose Server → Secret Key. So it + # is a cleartext handshake token by design, not a protected secret (which is why + # it lives in this non-secret env block rather than acq's secret path — and why + # acq's inability to inject a host-less secret is moot). Operators MAY override + # it per sandbox with `ACQ ... -e GOOSE_SERVER__SECRET_KEY=...` if they prefer a + # unique value. See docs/decisions/0002-plaintext-http-loopback.md. + GOOSE_SERVER__SECRET_KEY: "goose-server-kit-local-loopback" + +agentContext: | + ## goose Server (goosed for goose Desktop) + + This sandbox runs the **goose ACP server** (`goose serve`, "goosed") in the + background on container port **3284** (ACP API + WebSocket). **It is installed at + create time and supervised by the kit's startup script on every sandbox start** — + including a detached `acq create` with nobody attached — so it comes up on its own. + + **The client is a NATIVE goose Desktop app on your host machine**, not a browser + UI served from the sandbox. All agent work (the interactive loop, tool execution, + MCP extensions, file operations) runs HERE in the sandbox; the desktop app is a + thin UI pointed at this server. + + **No terminal needed to bring it up.** A single + `acq create --name shell ` (with this kit applied) starts the + server; then find the host port with `acq ports ` and point goose Desktop + at it. + + **Reaching it from the host.** The kit publishes container port 3284; acq's active + backend maps it to an ephemeral host loopback port, per sandbox. Find it, then in + goose Desktop go to **Settings → goose Server**, enable **Use external server**, + set the **URL** to `http://127.0.0.1:`, and set the **Secret + Key** to the kit's `GOOSE_SERVER__SECRET_KEY` value (default + `goose-server-kit-local-loopback`). goose Desktop 1.50.0 requires the secret-key + handshake, so an empty Secret Key will fail to connect. Leave Certificate + Fingerprint empty (HTTP, no TLS in v1). A full Quit + reopen of Desktop is needed + after changing these settings. + + ``` + acq ports # host port for 3284 + # goose Desktop → Settings → goose Server → Use external server + # URL: http://127.0.0.1: + # Secret Key: goose-server-kit-local-loopback + ``` + + **Provider.** The kit ships a goose **custom provider** named `custom_usai` + (OpenAI-compatible) pointed at the GSA **USAi** gateway (`api.gsa.usai.gov`), so a + goose Desktop configured with that same `custom_usai` provider can drive this + server directly. The API key is injected at runtime by `acq` as + `CUSTOM_USAI_API_KEY` (the provider's `api_key_env`) and is not stored in this + sandbox. In goose Desktop, pick the **USAi** provider and a model like + `claude-sonnet-5`. + + **Auth.** The server runs with a shared secret-key handshake + (`GOOSE_SERVER__SECRET_KEY`) that goose Desktop 1.50.0 requires. This is a + cleartext loopback handshake token (you paste the same value into Desktop), not a + protected credential; the sandbox is the security boundary and the published port + is host loopback only. + + Logs: `~/.local/state/goose/goose-serve.log` (the server) and + `~/.local/state/goose/goose-config-merge.log` (the config merge). The server runs + under a respawn loop (`supervisor:goose-serve`), so if it exits — for example after + a self-update — it comes back a few seconds later on its own; the log shows + `[supervisor] ... restarting` lines when it happens. + +# No backend_extras / backend_shortcuts: the port and background flag are declared +# with the NEUTRAL publishedPorts + background vocabulary above, which acq's kit +# translation feeds to whatever backend it has active. The install script, the +# supervise/merge startup script, and the config helpers are all backend-agnostic. +backend_shortcuts: + sbx: {} + msb: {} diff --git a/integrations/isolation/acq-kits/kits.yaml b/integrations/isolation/acq-kits/kits.yaml index 2805e347..efb63ff8 100644 --- a/integrations/isolation/acq-kits/kits.yaml +++ b/integrations/isolation/acq-kits/kits.yaml @@ -80,6 +80,42 @@ kits: ONE port. The daemon runs unsecured (no PASEO_PASSWORD); the sandbox is the security boundary and the port is host loopback only. + goose-server: + backends: [sbx, msb] + parity: | + Opt-in CHAT-HARNESS mixin: runs the goose ACP server (`goose serve`, + "goosed") inside the sandbox and publishes its port so a NATIVE goose Desktop + app on the host drives it as an external server (goose's documented + remote-server pattern). Written entirely in the neutral vocabulary with no + backend shortcut or extras. The goose CLI is installed by an INSTALL-phase + command (create-time, root): a single pinned, sha256-verified static binary + from GitHub Releases, arch-selected for x86_64/aarch64 guests (msb microVMs + are aarch64 on Apple Silicon), so both backends install the same verified + artifact. The single published container port (3284, goosed's ACP API + + WebSocket) is declared with the neutral top-level `publishedPorts` list and + the never-exiting startup supervisor with the neutral `background: true` + flag; acq's kit translation maps both to each backend's native port-publish + and detached-hook primitives, so the port is published and the server + supervised at create time on both backends with no manual step. In-guest bind + is 0.0.0.0:3284 (not loopback) so acq's create-time publish reaches the guest + on msb; the host side stays loopback-only. The server runs with a SHARED + SECRET-KEY handshake (GOOSE_SERVER__SECRET_KEY) over plaintext HTTP on the + host-loopback port — goose Desktop 1.50.0's ACP client always sends a secret, + so an unauthenticated server is rejected with "server cannot connect". The + secret is a cleartext loopback handshake token in the kit env (also pasted + into Desktop), not a protected credential; the sandbox is the security + boundary. The LLM provider is a goose CUSTOM provider named + `custom_usai` (OpenAI-compatible) that the kit ships as + custom_providers/custom_usai.json (base_url https://api.gsa.usai.gov/api/v1/ + chat/completions) and stages into ~/.config/goose/custom_providers/ at + startup — matching the provider a GSA goose Desktop uses, so Desktop can drive + the external server (a named-provider mismatch would fail agent init). The API + key is injected at runtime by acq as CUSTOM_USAI_API_KEY (the provider's + api_key_env) via a CUSTOM secret service bound to api.gsa.usai.gov (`acq + secret set -g goose-usai --host api.gsa.usai.gov --env CUSTOM_USAI_API_KEY`), + never stored in the kit. No backend shortcut. The install/supervise/merge/ + provider-stage scripts and the config helper are backend-agnostic. + prime-agent: backends: [sbx, msb] parity: | diff --git a/integrations/providers/usai/README.md b/integrations/providers/usai/README.md index 98ebece5..5c97b1a6 100644 --- a/integrations/providers/usai/README.md +++ b/integrations/providers/usai/README.md @@ -13,8 +13,13 @@ live feeds -> build-catalog.mjs -> catalog.json -> per-harness emitters - **live feeds** — the USAi models list plus enrichment metadata. - **build-catalog.mjs** — derives `catalog.json` (added in a later issue). - **catalog.json** — the generated catalog. **GENERATED — do not hand-edit.** -- **per-harness emitters** — render `catalog.json` into each harness config - (e.g. the OpenCode `opencode.jsonc` model block). +- **per-harness emitters** — render `catalog.json` into each harness config: + - `emitters/opencode.mjs` — the OpenCode `opencode.jsonc` model block. + - `emitters/prime-agent.mjs` — the prime-agent kit `models.json`. + - `emitters/goose.mjs` — the goose-server kit `custom_providers/custom_usai.json` + (a goose custom provider; `name` per model is the catalog model **id** goose + selects by, and `api_key_env` is the goose guest's injected env-var name + `CUSTOM_USAI_API_KEY`, not the catalog's `USAI_API_KEY`). ## Files @@ -25,10 +30,13 @@ live feeds -> build-catalog.mjs -> catalog.json -> per-harness emitters ## Safety guarantee -A byte-exact round-trip test against the shipped `opencode.jsonc` is the safety -guarantee: the emitter must reproduce the current, human-reviewed config exactly. -That test is the gate that lets us treat `catalog.json` as the source of truth -without silently changing any shipped kit. +Byte-exact round-trip / lockstep tests against each shipped config are the safety +guarantee: every emitter must reproduce the current, human-reviewed shipped file +exactly (`tests/emitters.test.mjs` for OpenCode; `tests/models-json-regen.test.mjs` +for prime-agent; `tests/goose-provider-regen.test.mjs` for goose). Those tests are +the gate that lets us treat `catalog.json` as the source of truth without silently +changing any shipped kit — if the catalog changes and a shipped config is not +regenerated, CI fails. ## `apiKeyEnv` is a name, not a secret diff --git a/integrations/providers/usai/emitters/goose.mjs b/integrations/providers/usai/emitters/goose.mjs new file mode 100644 index 00000000..db609bc5 --- /dev/null +++ b/integrations/providers/usai/emitters/goose.mjs @@ -0,0 +1,155 @@ +// ============================================================================= +// goose.mjs — the RENDER half of the USAi catalog pipeline (goose target). +// +// Data flow: +// live feeds -> build-catalog.mjs -> catalog.json -> emitters/goose.mjs +// +// This module is a PURE function: given the parsed catalog.json object it returns +// the EXACT text of a goose CUSTOM PROVIDER definition file — a serialized JSON +// string ready to write to the goose-server kit's shipped path: +// integrations/isolation/acq-kits/goose-server/files/home/goose-config/ +// custom_providers/custom_usai.json +// +// goose Desktop drives an external `goose serve` by NAMED provider + model. The +// goose-server kit ships this custom provider so the in-sandbox server exposes the +// SAME `custom_usai` provider a GSA goose Desktop uses. Generating it from the one +// catalog keeps the goose model list in lockstep with OpenCode/prime-agent instead +// of drifting as a hand-maintained snapshot. A byte-exact lockstep test +// (tests/goose.test.mjs) asserts this emitter reproduces the shipped file. +// +// Neutral (catalog) -> goose custom-provider translation: +// Provider-level (constants + gateway): +// name "custom_usai" (the provider name Desktop selects) +// engine "openai" (goose's OpenAI-compatible engine) +// display_name "USAi" +// api_key_env "CUSTOM_USAI_API_KEY" — the ENV VAR NAME goose reads the +// key from. This is DELIBERATELY the goose-kit's own env +// name, NOT catalog.gateway.apiKeyEnv (USAI_API_KEY): acq +// injects the key into the goose guest as CUSTOM_USAI_API_KEY +// (see the goose-server kit ADR-0003), and goose's custom +// provider reads exactly this name. It is an env-var NAME, +// never a secret value. +// base_url catalog.gateway.baseUrl + "/chat/completions" +// (goose posts to the chat-completions endpoint; the catalog +// baseUrl is the api/v1 root) +// Per model (goose CustomProviderModel shape): +// name <- catalog model.id (goose selects models by this id, +// e.g. "claude_4_5_sonnet" — the catalog ID, not the +// human label) +// context_limit <- catalog contextWindow +// input_token_cost <- catalog cost.input / 1e6 (null if absent) +// output_token_cost <- catalog cost.output / 1e6 (null if absent) +// UNIT CONVERSION: the catalog stores cost as USD PER 1,000,000 TOKENS +// (e.g. Sonnet 5 input=2 meaning $2/1M), but goose's custom-provider +// input_token_cost/output_token_cost are USD PER SINGLE TOKEN. Emitting the +// per-1M number verbatim makes goose overstate session cost by 1e6 (a +// ~$0.25 turn shows as ~$249,406). So we DIVIDE by 1e6 here. OpenCode and +// prime-agent keep the per-1M value because their configs use per-1M cost +// fields; goose is the odd one out and needs the conversion. +// currency "USD" when any cost is present, else null +// supports_cache_control true when the catalog model declares cost.cacheRead +// or cost.cacheWrite, else null +// reasoning false (constant — the catalog carries no reasoning flag; +// goose defaults reasoning off for custom providers) +// Model order: catalog (vendor-grouped) order preserved, matching the other +// emitters, so all three harnesses list models in one canonical order. +// +// INTENTIONAL LOSS: the tiered `costAbove200kContext` field is DROPPED — goose's +// custom-provider model shape has a single flat cost pair, so the >200k-context +// pricing tier cannot be represented (same documented loss as the prime-agent +// emitter). +// +// No fetch, no fs — the caller passes the parsed catalog and serializes/writes. +// ============================================================================= + +// The provider NAME Desktop selects and the kit's config primer marks active. +const PROVIDER_NAME = "custom_usai" +// goose's OpenAI-compatible custom-provider engine. +const ENGINE = "openai" +const DISPLAY_NAME = "USAi" +// The ENV VAR NAME goose reads the key from — the goose-server kit's own injected +// name (acq injects the USAi key as CUSTOM_USAI_API_KEY into the goose guest). +// NOT catalog.gateway.apiKeyEnv. This is a name, never a value. +const API_KEY_ENV = "CUSTOM_USAI_API_KEY" +// goose posts to the chat-completions endpoint under the gateway's api/v1 root. +const CHAT_COMPLETIONS_PATH = "/chat/completions" + +// Catalog cost is USD per 1,000,000 tokens; goose wants USD per single token. +const TOKENS_PER_COST_UNIT = 1_000_000 + +// Map one catalog model to a goose custom-provider model object, in the exact key +// order the shipped custom_usai.json uses. +function renderModel(model) { + const cost = model.cost && typeof model.cost === "object" ? model.cost : {} + const hasInput = typeof cost.input === "number" + const hasOutput = typeof cost.output === "number" + const hasAnyCost = hasInput || hasOutput + const supportsCache = + typeof cost.cacheRead === "number" || typeof cost.cacheWrite === "number" ? true : null + return { + name: model.id, + context_limit: model.contextWindow, + // Convert per-1M-token catalog cost -> per-token cost for goose (see header). + input_token_cost: hasInput ? cost.input / TOKENS_PER_COST_UNIT : null, + output_token_cost: hasOutput ? cost.output / TOKENS_PER_COST_UNIT : null, + currency: hasAnyCost ? "USD" : null, + supports_cache_control: supportsCache, + reasoning: false, + // costAbove200kContext is intentionally dropped — no goose equivalent. + } +} + +/** + * Emit a goose custom_usai provider definition (serialized) from a parsed + * catalog.json object. + * + * @param {object} catalog parsed catalog.json (schema usai-model-catalog/v1) + * @returns {string} pretty-printed JSON (2-space indent) with a trailing newline, + * ready to write directly as custom_providers/custom_usai.json + */ +export function emitGooseProviderFromCatalog(catalog) { + if (!catalog || typeof catalog !== "object") { + throw new TypeError("emitGooseProviderFromCatalog: catalog must be an object") + } + const gateway = catalog.gateway && typeof catalog.gateway === "object" ? catalog.gateway : {} + const models = Array.isArray(catalog.models) ? catalog.models : [] + + const baseUrl = + typeof gateway.baseUrl === "string" ? `${gateway.baseUrl}${CHAT_COMPLETIONS_PATH}` : undefined + + const doc = { + name: PROVIDER_NAME, + engine: ENGINE, + display_name: DISPLAY_NAME, + description: + "Custom USAi provider (GSA USAi OpenAI-compatible gateway). Shipped by the " + + "goose-server acq kit so the in-sandbox goosed server exposes the SAME " + + "provider a goose Desktop client already uses, letting Desktop drive the " + + "external server without reconfiguring. The API key is NOT in this file: " + + "goose reads it from the CUSTOM_USAI_API_KEY env var (api_key_env), which " + + "acq injects at runtime bound to api.gsa.usai.gov. GENERATED from the USAi " + + "catalog by the goose emitter — do not hand-edit; regenerate via the emitter.", + api_key_env: API_KEY_ENV, + base_url: baseUrl, + models: models.map(renderModel), // ARRAY, catalog (vendor-grouped) order preserved + headers: null, + timeout_seconds: null, + supports_streaming: true, + requires_auth: true, + catalog_provider_id: null, + base_path: null, + env_vars: null, + dynamic_models: null, + skip_canonical_filtering: false, + model_doc_link: null, + setup_steps: [], + fast_model: null, + preserves_thinking: true, + emit_clear_thinking: false, + setup: null, + } + + return `${JSON.stringify(doc, null, 2)}\n` +} + +export default emitGooseProviderFromCatalog diff --git a/integrations/providers/usai/tests/goose-provider-regen.test.mjs b/integrations/providers/usai/tests/goose-provider-regen.test.mjs new file mode 100644 index 00000000..557deb0a --- /dev/null +++ b/integrations/providers/usai/tests/goose-provider-regen.test.mjs @@ -0,0 +1,102 @@ +// ============================================================================= +// goose-provider-regen.test.mjs — byte-exact lockstep guard for the goose-server +// kit's shipped custom_usai.json provider definition. +// +// The goose-server kit ships a committed custom_providers/custom_usai.json that is +// GENERATED by the USAi catalog goose emitter. This test is the load-bearing +// guarantee that the two stay in lockstep: emitting from the SAME catalog.json +// that ships in this repo must reproduce the committed kit file BYTE-FOR-BYTE. +// +// If catalog.json changes and someone forgets to regenerate the kit file (re-run +// the emitter and write its output to the kit path), this test fails CI. +// +// It also asserts the committed artifact carries NO key material — belt-and- +// suspenders, since it is a shipped file that ends up on disk inside the kit. +// +// The test reads the REAL shipped files via relative paths (NOT copied fixtures), +// so drift fails CI. Mirrors models-json-regen.test.mjs (prime-agent). +// ============================================================================= + +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { test } from "node:test" + +import { emitGooseProviderFromCatalog } from "../emitters/goose.mjs" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const USAI_DIR = path.resolve(HERE, "..") +const REPO_ROOT = path.resolve(USAI_DIR, "../../..") + +const CATALOG_JSON = path.join(USAI_DIR, "catalog.json") +const KIT_PROVIDER_JSON = path.join( + REPO_ROOT, + "integrations/isolation/acq-kits/goose-server/files/home/goose-config/custom_providers/custom_usai.json", +) + +// On mismatch, find the first differing character offset and print a small window +// around it (expected vs actual) to make debugging fast. +function firstMismatchReport(expected, actual) { + const len = Math.min(expected.length, actual.length) + let i = 0 + for (; i < len; i++) { + if (expected[i] !== actual[i]) break + } + const ctx = 40 + const from = Math.max(0, i - ctx) + const to = i + ctx + return [ + `first mismatch at offset ${i}`, + ` expected len=${expected.length} actual len=${actual.length}`, + ` expected: ${JSON.stringify(expected.slice(from, to))}`, + ` actual: ${JSON.stringify(actual.slice(from, to))}`, + ].join("\n") +} + +test("committed kit custom_usai.json equals the emitter output byte-for-byte", () => { + const committed = readFileSync(KIT_PROVIDER_JSON, "utf8") + const catalog = JSON.parse(readFileSync(CATALOG_JSON, "utf8")) + const emitted = emitGooseProviderFromCatalog(catalog) + + if (committed !== emitted) { + // Print a targeted diff before the assertion fails: this signals the kit file + // is stale — regenerate it by re-running the emitter and writing its output + // to the kit path. + // eslint-disable-next-line no-console + console.error(firstMismatchReport(emitted, committed)) + } + assert.strictEqual( + committed, + emitted, + "committed goose-server custom_usai.json is stale — regenerate from catalog.json via the USAi catalog goose emitter", + ) +}) + +test("committed kit custom_usai.json ships every catalog model and the env-var-name api_key_env", () => { + const catalog = JSON.parse(readFileSync(CATALOG_JSON, "utf8")) + const committed = JSON.parse(readFileSync(KIT_PROVIDER_JSON, "utf8")) + assert.ok(Array.isArray(committed.models), "models must be an array") + // DERIVED from the catalog, never a hardcoded count. + assert.ok(catalog.models.length > 0, "catalog must not be empty") + assert.equal( + committed.models.length, + catalog.models.length, + "kit custom_usai.json must ship exactly the catalog's models", + ) + assert.equal( + committed.api_key_env, + "CUSTOM_USAI_API_KEY", + "api_key_env must be the goose guest's env-var NAME, never a secret", + ) + assert.equal(committed.name, "custom_usai", "provider name Desktop selects") +}) + +test("committed kit custom_usai.json contains NO key material (shipped artifact)", () => { + const committed = readFileSync(KIT_PROVIDER_JSON, "utf8") + assert.doesNotMatch(committed, /api-key-/i) + assert.doesNotMatch(committed, /\bsk-[A-Za-z0-9]/) + assert.doesNotMatch(committed, /Bearer\s+\S/i) + assert.doesNotMatch(committed, /[A-Za-z0-9+/]{32,}={0,2}/, "no long base64-ish secret blob") + assert.doesNotMatch(committed, /\b[0-9a-f]{32,}\b/i, "no long hex secret blob") +}) diff --git a/integrations/providers/usai/tests/goose.test.mjs b/integrations/providers/usai/tests/goose.test.mjs new file mode 100644 index 00000000..1912b484 --- /dev/null +++ b/integrations/providers/usai/tests/goose.test.mjs @@ -0,0 +1,175 @@ +// ============================================================================= +// goose.test.mjs — shape + no-secret + no-leakage tests for the goose emitter. +// +// The goose-server kit consumes a custom-provider file (custom_usai.json) where +// `models` is an ARRAY of goose CustomProviderModel objects (each with a `name` +// that is the catalog model ID goose selects by), plus provider-level fields +// (name, engine, api_key_env, base_url). These tests assert the emitter produces +// THAT shape and specifically does NOT leak the OpenCode shape (limit object, +// snake_case cost keys, context_over_200k) or the prime-agent shape +// (contextWindow/maxTokens/cost object), does NOT embed any real key material, +// and drops the tiered costAbove200kContext field goose cannot represent. +// +// Reads the REAL shipped catalog.json via a relative path so catalog drift is +// exercised by these assertions too. Mirrors prime-agent.test.mjs. +// ============================================================================= + +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { test } from "node:test" + +import { emitGooseProviderFromCatalog } from "../emitters/goose.mjs" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const USAI_DIR = path.resolve(HERE, "..") +const CATALOG_JSON = path.join(USAI_DIR, "catalog.json") + +const catalog = JSON.parse(readFileSync(CATALOG_JSON, "utf8")) + +// Emitter returns a serialized string; tests parse it back for structural +// assertions AND scan the raw string for leakage / secrets. +const serialized = emitGooseProviderFromCatalog(catalog) +const doc = JSON.parse(serialized) + +test("emits a ready-to-write JSON string (2-space indent, trailing newline)", () => { + assert.equal(typeof serialized, "string") + assert.ok(serialized.endsWith("\n"), "output must end with a trailing newline") + // 2-space indent: the first nested key is indented by exactly 2 spaces. + assert.match(serialized, /\n {2}"engine": "openai"/) + assert.deepEqual(JSON.parse(serialized), doc) +}) + +test("goose provider-level fields: name/engine/display_name/api_key_env/base_url", () => { + assert.equal(doc.name, "custom_usai", "provider name Desktop selects") + assert.equal(doc.engine, "openai", "goose OpenAI-compatible engine") + assert.equal(doc.display_name, "USAi") + // api_key_env is the goose guest's injected env-var NAME, NOT the catalog's + // gateway.apiKeyEnv (USAI_API_KEY). It is a name, never a secret value. + assert.equal(doc.api_key_env, "CUSTOM_USAI_API_KEY") + assert.notEqual( + doc.api_key_env, + catalog.gateway.apiKeyEnv, + "goose uses its own injected env-var name, not the catalog gateway's", + ) + // base_url is the catalog gateway root + the chat-completions path goose posts to. + assert.equal(doc.base_url, `${catalog.gateway.baseUrl}/chat/completions`) +}) + +test("goose ARRAY shape: models is an array; each has a name = catalog model id", () => { + assert.ok(Array.isArray(doc.models), "models must be an array") + for (const m of doc.models) { + assert.equal(typeof m.name, "string") + assert.ok(m.name.length > 0, "every model must carry a non-empty name") + assert.equal(typeof m.context_limit, "number") + // cost fields are number|null; currency string|null; reasoning boolean. + assert.ok(m.input_token_cost === null || typeof m.input_token_cost === "number") + assert.ok(m.output_token_cost === null || typeof m.output_token_cost === "number") + assert.ok(m.currency === null || typeof m.currency === "string") + assert.ok(m.supports_cache_control === null || typeof m.supports_cache_control === "boolean") + assert.equal(typeof m.reasoning, "boolean") + } +}) + +// The count is DERIVED from the catalog on purpose: the gateway's model list +// changes over time, so a literal here would go stale and fail for the wrong +// reason. The emitter's contract is "emit every catalog model, drop none". +test("model count matches the catalog", () => { + assert.equal(doc.models.length, catalog.models.length) + assert.ok(doc.models.length > 0, "catalog must not be empty") +}) + +test("goose model `name` is the catalog model ID (what goose selects by), in catalog order", () => { + assert.deepEqual( + doc.models.map((m) => m.name), + catalog.models.map((m) => m.id), + ) +}) + +test("context_limit maps from catalog.contextWindow", () => { + for (let i = 0; i < doc.models.length; i++) { + assert.equal(doc.models[i].context_limit, catalog.models[i].contextWindow) + } +}) + +test("token costs are per-TOKEN (catalog per-1M / 1e6); currency USD when present", () => { + for (let i = 0; i < doc.models.length; i++) { + const src = catalog.models[i].cost || {} + const out = doc.models[i] + // goose input_token_cost/output_token_cost are USD per single token, while the + // catalog stores USD per 1,000,000 tokens — the emitter divides by 1e6. + assert.equal(out.input_token_cost, "input" in src ? src.input / 1_000_000 : null) + assert.equal(out.output_token_cost, "output" in src ? src.output / 1_000_000 : null) + const hasAnyCost = "input" in src || "output" in src + assert.equal(out.currency, hasAnyCost ? "USD" : null) + } +}) + +test("per-token cost magnitude is sane (a per-1M value would be ~1e6 too large)", () => { + // Guard the exact bug this conversion fixes: a model priced a few dollars per + // 1M tokens must emit a per-token cost well under $0.01, not a few-dollars value. + for (const m of doc.models) { + if (typeof m.input_token_cost === "number") { + assert.ok( + m.input_token_cost < 0.01, + `${m.name} input_token_cost ${m.input_token_cost} looks like a per-1M value, not per-token`, + ) + } + if (typeof m.output_token_cost === "number") { + assert.ok( + m.output_token_cost < 0.01, + `${m.name} output_token_cost ${m.output_token_cost} looks like a per-1M value, not per-token`, + ) + } + } +}) + +test("supports_cache_control is true only when the catalog declares cacheRead/cacheWrite", () => { + for (let i = 0; i < doc.models.length; i++) { + const src = catalog.models[i].cost || {} + const expected = "cacheRead" in src || "cacheWrite" in src ? true : null + assert.equal(doc.models[i].supports_cache_control, expected) + } + // Sanity floor: several catalog models declare cache pricing. + const withCache = catalog.models.filter( + (m) => m.cost && (m.cost.cacheRead !== undefined || m.cost.cacheWrite !== undefined), + ) + assert.ok(withCache.length >= 5, `expected cache pricing on several models (found ${withCache.length})`) +}) + +test("NO OpenCode- or prime-agent-shaped leakage", () => { + for (const m of doc.models) { + assert.equal(m.limit, undefined, "no OpenCode limit object") + assert.equal(m.contextWindow, undefined, "goose uses context_limit, not contextWindow") + assert.equal(m.maxTokens, undefined, "no prime-agent maxTokens") + assert.equal(m.cost, undefined, "goose has no nested cost object") + } + assert.doesNotMatch(serialized, /"limit"/) + assert.doesNotMatch(serialized, /cache_read/) + assert.doesNotMatch(serialized, /cache_write/) + assert.doesNotMatch(serialized, /context_over_200k/) + assert.doesNotMatch(serialized, /"maxTokens"/) + assert.doesNotMatch(serialized, /"contextWindow"/) +}) + +test("costAbove200kContext is dropped (goose has no tiered-pricing field)", () => { + const tiered = catalog.models.filter((m) => m.costAbove200kContext) + assert.ok(tiered.length > 0, "catalog must carry at least one tiered-pricing model") + for (const src of tiered) { + const out = doc.models.find((m) => m.name === src.id) + assert.ok(out, `emitted output must still contain ${src.id}`) + assert.equal(out.costAbove200kContext, undefined, `costAbove200kContext dropped for ${src.id}`) + } + assert.doesNotMatch(serialized, /costAbove200kContext/) +}) + +test("api_key_env is an env-var NAME, and NO real key material appears anywhere", () => { + assert.equal(doc.api_key_env, "CUSTOM_USAI_API_KEY") + // No value that looks like a real secret leaked into the serialized output. + assert.doesNotMatch(serialized, /api-key-/i) + assert.doesNotMatch(serialized, /\bsk-[A-Za-z0-9]/) + assert.doesNotMatch(serialized, /Bearer\s+\S/i) + assert.doesNotMatch(serialized, /[A-Za-z0-9+/]{32,}={0,2}/, "no long base64-ish secret blob") + assert.doesNotMatch(serialized, /\b[0-9a-f]{32,}\b/i, "no long hex secret blob") +})