Repository navigation
Conversation
Add an opt-in OpenDesign ACQ kit that runs the headless browser workspace inside the sandbox, reuses the existing OpenCode CLI, and keeps model credentials on the ACQ/OpenCode path. Includes pinned source install, kit-managed Node 24, persistent OpenDesign state, startup supervision, live/offline verification, troubleshooting, and design rationale. Co-authored-by: OpenCode Agent <agent@gsa.gov>
…ack-gated routes
Two independent defects, both found by running the kit in a live msb
sandbox: every prompt failed after ~2m43s, and Settings -> About ->
Export diagnostics returned 403.
1. OpenCode never read the usai-provider config, so every run failed.
OpenDesign launches agent children with OD_SANDBOX_MODE=1, which
rewrites HOME and XDG_CONFIG_HOME to a private agent home under
OD_DATA_DIR (upstream apps/daemon/src/sandbox-mode.ts). OpenCode
resolves its global config from those variables, so it read the empty
stub it had auto-created at
$OD_DATA_DIR/sandbox/config/opencode/opencode.jsonc and never saw
~/.config/opencode/opencode.jsonc. With no `usai` provider defined it
fell back to its own hosted gateway, which this kit's egress
allowlist does not include, then exhausted its retry budget:
providerID=opencode modelID=big-pickle
url: https://opencode.ai/zen/v1/chat/completions
AI_APICallError: Cannot connect to API
The run record agreed: status=failed, failureDetail=stream_error,
163s wall time. Confirmed the mechanism by running `opencode models`
under the two environments -- the sandbox env listed only
opencode/* models, the host env listed usai/*.
Fix: startup exports OPENCODE_CONFIG, OpenCode's documented
absolute-path override, which survives the HOME/XDG rewrite. This
names a config PATH only; no key material moves into OpenDesign
state. USAI_API_KEY stays in the environment and is resolved by that
config's own {env:USAI_API_KEY} substitution at run time, preserving
docs/decisions/acq-owned-byok.md. Falls back .jsonc -> .json, honors a
pre-set value, and logs which config it chose (or warns when none
exists). Note there is no UI workaround for this: upstream's
Settings -> Local CLI allowlist for the opencode agent accepts only
OPENCODE_BIN, not a config path or a key.
2. The 0.0.0.0 bind made OpenDesign 403 its own browser requests.
Upstream gates a subset of routes on the request PEER address being
loopback (requireLocalDaemonRequest,
apps/daemon/src/http/local-daemon-request.ts). acq/msb port
publishing dials the guest network IP, not guest 127.0.0.1, so a
daemon on 0.0.0.0 sees a guest-network peer for every request from the
host and answers 403 on exactly three routes while the rest of the UI
works:
GET /api/diagnostics/export <- the Export logs button
POST /api/strategies/od-next/rollout <- silently inert
POST /api/diagnostics/chat-scroll-forensics
The daemon's own failure journal
($OD_DATA_DIR/diagnostics/environment-evidence.json) listed precisely
those three and nothing else, which rules out the export handler --
`od diagnostics export` wrote a valid bundle on the first try while
the HTTP route 403'd.
Fix: bind the daemon to guest 127.0.0.1 and publish it with a small
supervised relay (opendesign-relay.mjs) that binds the guest network
address(es) and forwards to loopback, so the daemon always observes a
loopback peer. The relay is a plain byte pipe -- no parsing, no header
rewriting -- so SSE run streams and websockets pass through unchanged.
It skips loopback interfaces (cannot collide with the daemon), treats
EADDRINUSE as benign, rescans every 10s for interfaces that appear
after boot, and is supervised separately so it survives a daemon
restart. Exposure is unchanged: the guest network interface was
already the reachable surface, and the host side of the mapping is
still loopback-only.
Rejected: documenting the CLI export instead (leaves od-next broken
and a UI button that always fails), and patching upstream's guard to
honor OD_ALLOWED_ORIGINS (right long-term fix, but weakens a
deliberate CSRF/SSRF boundary; being raised upstream instead).
Also: amended docs/decisions/disable-api-auth-loopback-boundary.md in
place with the measured route table and the rejected alternatives, since
the PR is unmerged and its 0.0.0.0 decision was simply wrong; extended
TROUBLESHOOTING.md with both symptoms, the exact log lines that identify
each, and the /proc/net/tcp check that distinguishes them; added 12
offline verify assertions (bind host, relay presence/shape,
OPENCODE_CONFIG export) and 6 live ones, including a regression gate that
makes the exact request the Export button makes and requires HTTP 200.
Verified in a live msb sandbox against open-design-v0.24.0, not mocked:
- offline gate: 25 passed, 0 failed, 0 skipped (STRICT_VERIFY=1)
- validate-kits.py --strict: all 8 kits valid
- pytest scripts/tests/: 401 passed
- make test-kits: 47 + 8 + 15 passed, 0 failed
- scan_unsafe_shell.py: 0 errors, 0 warnings
- validate_repo.py: all validators passed
- generate_index.py --check / generate_catalog.py --check: up to date
- markdownlint-cli2: 0 issues
- POST /api/chat through the relay with the real USAi path:
providerID=usai modelID=claude-opus-5
text_delta "RELAY-AND-CONFIG-OK", status succeeded, exit_code 0
- through the relay: /api/health 200, /api/diagnostics/export 200
(valid 10-entry zip), /api/strategies/od-next/rollout 200,
chat-scroll-forensics POST 200 -- all 403 before this change
- /proc/net/tcp: daemon on 0100007F (127.0.0.1) only, relay on the
guest address; no 00000000 listener
- grep -R USAI_API_KEY over OD_DATA_DIR: absent (rc=1)
- relay edge cases: duplicate instance logs "already in use" and does
not crash; bad/missing port argument exits 2 with usage; daemon kill
leaves the relay up and it serves again on daemon restart
- OPENCODE_CONFIG resolution exercised for all four cases (.jsonc,
.json-only, none, pre-set)
Rollback: `git revert` this commit. It touches only the opendesign kit
plus that kit's kits.yaml parity entry; reverting restores the 0.0.0.0
bind and drops the relay, reinstating both defects but nothing else.
Security impact: no new attack surface. The relay narrows what the daemon
accepts (loopback peers only) rather than widening it, and adds no
authentication bypass -- OD_DISABLE_API_AUTH and the host-loopback
publish boundary are unchanged. The OPENCODE_CONFIG export moves a file
path, not a credential.
AI-assisted: investigated and authored by an AI agent; human review
required before merge.
Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
Live-sandbox findings: two defects, both reproduced and fixedRan this kit in an 1. OpenCode never reads the
|
| Route | bound 0.0.0.0 |
bound 127.0.0.1 + relay |
|---|---|---|
GET /api/health |
200 | 200 |
GET /api/diagnostics/export |
403 | 200 |
POST /api/strategies/od-next/rollout |
403 | 200 |
POST /api/diagnostics/chat-scroll-forensics |
403 | 200 |
So OD Next was silently inert too, not just the export button. The daemon's own failure journal ($OD_DATA_DIR/diagnostics/environment-evidence.json) lists precisely those three and nothing else — which rules out the export handler; od diagnostics export wrote a valid bundle on the first try while the HTTP route 403'd.
Fix: bind the daemon to guest 127.0.0.1 and publish it with a small supervised relay that binds the guest network address and forwards to loopback, so the daemon always sees a loopback peer. Plain byte pipe — no parsing or header rewriting, so SSE run streams and websockets pass through unchanged. Exposure is unchanged: the guest interface was already the reachable surface and the host side of the mapping is still loopback-only.
Rejected: documenting the CLI export instead (leaves od-next broken and a button that always fails); patching upstream's guard to honor OD_ALLOWED_ORIGINS (right long-term fix, but weakens a deliberate CSRF/SSRF boundary — raising it upstream instead).
Verification (live, not mocked; open-design-v0.24.0)
offline gate (STRICT_VERIFY=1) 25 passed, 0 failed, 0 skipped
validate-kits.py --strict all 8 kits valid
pytest scripts/tests/ 401 passed
make test-kits 47 + 8 + 15 passed, 0 failed
scan_unsafe_shell.py 0 errors, 0 warnings
validate_repo.py all validators passed
generate_index/catalog --check up to date
markdownlint-cli2 0 issues
POST /api/chat through the relay, real USAi path:
providerID=usai modelID=claude-opus-5
text_delta "RELAY-AND-CONFIG-OK" status succeeded exit_code 0
through the relay: /api/health 200, /api/diagnostics/export 200 (valid
10-entry zip), od-next/rollout 200, chat-scroll-forensics 200
— all 403 before this change
/proc/net/tcp: daemon on 0100007F only; relay on guest addr; no 00000000
grep -R USAI_API_KEY over OD_DATA_DIR: absent (rc=1)
relay edges: duplicate instance logs "already in use" and survives;
bad port arg exits 2; daemon kill leaves relay up and it recovers
Fix pushed to plant-chipmunk. The ADR was amended in place (its 0.0.0.0 decision was simply wrong), TROUBLESHOOTING.md covers both symptoms with the exact log lines that identify each, and scripts/verify gained 12 offline + 6 live assertions — including a regression gate that makes the exact request the Export button makes and requires HTTP 200.
Investigated and authored with AI assistance; human review required before merge.
Pin the relay's upstream socket localAddress to 127.0.0.1 so peer-loopback-gated OpenDesign routes see loopback consistently, and make live verification print guarded-route response bodies for diagnosis. Co-authored-by: OpenCode Agent <agent@gsa.gov>
|
Follow-up pushed in Changes:
Local verification after the patch:
Please rerun host live verification from the updated branch: RUN_ACQ=1 scripts/verifyIf diagnostics export still returns 403, the verifier will now include the exact daemon error body so we can distinguish peer vs host vs origin validation. |
Found by running the new live assertions against a real daemon instead of trusting them, which is what I should have done before adding them. All three are bugs in the verification I wrote in e568770, not in the kit. 1. The relay check was a guaranteed false PASS. `pgrep -f "supervisor:opendesign-relay"` matches every process whose full cmdline contains that text -- including the shell `acq exec` spawns to run the check, whose cmdline *is* the check. Demonstrated in this sandbox, which runs the unfixed kit and therefore has no relay anywhere: $ pgrep -af opendesign-relay # -> nothing $ sh -c 'pgrep -f "supervisor:opendesign-relay" && echo yes' yes # <- FALSE POSITIVE So the one assertion guarding the mechanism this PR adds could never fail. Worse than a missing check: it reports success for its own absence. Fixed with a bracketed first character (`[o]pendesign-relay\.mjs`), which cannot match the literal pattern text in the checking shell's cmdline but does match the real process. Also retargeted from the supervisor wrapper to the relay's own node process, since that is what actually holds the published port. Verified in all four states: relay absent (no), bare relay (yes), relay under its supervisor (yes), and with the plain pattern for contrast (yes when it should be no). 2. The OPENCODE_CONFIG check read the wrong process. `pgrep -f "apps/daemon/dist/cli.js" | head -n1` resolved to pid 3952 here -- the supervisor *shell*, not the daemon. The shell's cmdline contains the same path, and it sorts first: 3952 comm=sh sh -c 'while :; do node apps/daemon/dist/cli.js ...' 3954 comm=MainThread node apps/daemon/dist/cli.js --host ... --port 7456 The supervisor does inherit the exports, so the check would usually still pass -- a wrong-target read that happens to agree, which hides the mistake rather than surfacing it. Fixed by anchoring on argv[0] being node, and additionally selecting by OD_PORT rather than first-match: a host can have more than one daemon alive, and "first pid wins" silently inspects an arbitrary one. Verified both directions on a host with two daemons running -- the port-scoped lookup correctly returned empty for the unfixed daemon on 7456 and the real config path for the fixed one on 7506, where first-match returned the wrong one. 3. The app-config check failed on normal use. It asserted `agentModels` is empty. But that is runtime state OpenDesign owns, and merely opening the UI is enough for the app to persist `agentModels.opencode.model` (observed: "default", written by OpenDesign, not chosen by a user) alongside orbit, projectLocations, and privacyDecisionAt. Against this sandbox's real config the check returned "no" -- reporting a kit defect for an untouched install. The invariant it was reaching for is "the *kit* must not seed a model", which is already proven offline against the startup source ("startup does not seed a default model"). So the live check now asserts only what the kit actually writes, and reports any configured model as SKIP with its value rather than failing. Verified against both a freshly seeded config (PASS, empty) and this sandbox's UI-touched one (PASS + informational SKIP). Verified, live, against a loopback daemon + relay pair on 7506 built from this branch (including bd2beef's localAddress change): - the three corrected assertions, in their exact scripted form: bind check PASS (0100007F + guest addr, no 00000000) relay running PASS (and correctly FAILs with no relay) OPENCODE_CONFIG PASS (resolves /home/agent/.config/...) app-config PASS on fresh and on UI-touched state - through the relay: /api/health 200, /api/diagnostics/export 200, /api/strategies/od-next/rollout 200, over both IPv4 and IPv6 - POST /api/chat through the relay: text_delta "LOCALADDR-OK", status succeeded, exit_code 0 -- confirms bd2beef's localAddress: 127.0.0.1 does not break the IPv6 inbound leg - offline gate: 26 passed, 0 failed, 0 skipped (STRICT_VERIFY=1) - validate-kits.py --strict: all 8 kits valid - pytest scripts/tests/: 401 passed - make test-kits: 47 + 8 + 15 passed, 0 failed - scan_unsafe_shell.py: 0 errors, 0 warnings - validate_repo.py: all validators passed - generate_index/catalog --check: up to date - markdownlint-cli2: 0 issues - git diff --check: clean Note for anyone reproducing the offline gate: run python with -B or PYTHONDONTWRITEBYTECODE=1. validate-kits.py walks the acq-kits directory and reports a stray __pycache__ as "missing spec.yaml", so a previous unbuffered run makes the next one fail on its own artifact. Pre-existing, unrelated to this branch, and not fixed here. Rollback: `git revert` this commit. It touches only scripts/verify; the kit's runtime behavior is unchanged by it. Security impact: none. Verification-only change. No runtime file, no credential path, and no network posture is touched. AI-assisted: investigated and authored by an AI agent; human review required before merge. Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
Respond to adversarial review by restricting relay peers to loopback/default gateway plus explicit overrides, requiring schema validation by default, correcting stale daemon-bind docs, and reducing sensitive troubleshooting output. Co-authored-by: OpenCode Agent <agent@gsa.gov>
|
Adversarial follow-up pushed in Findings addressed:
Accepted risk left explicit:
Verification in this sandbox:
Full schema validation and live |
Log accepted relay peers once and include relay logs when guarded host-route checks fail, so host verification can distinguish relay bypass from upstream request validation failures. Co-authored-by: OpenCode Agent <agent@gsa.gov>
Do not inherit backend-provided OD_BIND_HOST for the OpenDesign daemon. Use a kit-specific override so ordinary service defaults like 0.0.0.0 cannot bypass the relay and break peer-loopback-gated routes. Co-authored-by: OpenCode Agent <agent@gsa.gov>
|
Follow-up pushed in The decisive clue was the host "bindHost":"0.0.0.0"That means the daemon was still inheriting a backend-provided Fix:
Local verification:
Please rerun host live verification from the updated branch. The daemon-status line should now report |
|
AI-assisted adversarial review (OpenCode), advisory — needs a human to confirm before it drives a change. Reviewed at head The four live-sandbox rounds show in the result: the relay rationale is documented to the route level, the peer allowlist fails closed, and the ADR lists the rejected alternatives honestly. I went after the parts a live run wouldn't catch. One finding I'd want resolved before merge, three smaller, and a set of things I checked that hold. Should-fix: the relay's peer allowlist is a guest-side control, and the guest is the untrusted party
That's a real narrowing over the So the accurate statement is: the relay narrows accidental reachability from the guest network, and does not constrain a hostile agent. That matters because on I'm not asking for a token — the sibling-kit posture is settled and the ADR argues it well. I'm asking the ADR's Consequences to say the relay is defence-in-depth rather than the boundary, and to name the shared-guest-network case for Nit: a malformed
|
Fix the live verifier readiness probe after the daemon/relay split so it checks the loopback daemon on 17456 while host verification continues to exercise published guest port 7456. Harden proxy CA handling, narrow the relay to the default-route guest interface, add a relay helper self-test, and update documentation for the source-build surface and relay trust boundary. Co-authored-by: OpenCode <bret.mogilefsky@gsa.gov>
|
Follow-up pushed in Changes since the last review:
Verification: Host live verification from the sandbox-capable host was rerun with Please re-review the OpenDesign kit changes when available. AI-assisted; human review required before merge. |
Co-authored-by: OpenCode Agent <agent@gsa.gov>
Co-authored-by: OpenCode Agent <agent@gsa.gov>
|
AI-assisted re-review (OpenCode), advisory — needs a human to confirm before it drives a change. Re-reviewed All four findings are resolved, and three of them were fixed more thoroughly than I asked for. No blockers from me. Verification of each prior findingShould-fix — the relay peer allowlist is a guest-side control, stated as the boundary. Fixed, and the new ADR wording is better than what I suggested: "defense-in-depth against accidental guest-network reachability. It is not the primary trust boundary and does not constrain a hostile process already running inside the sandbox: the agent can run code with the sandbox user's authority, and in local ACQ environments may have enough privilege to stop or replace the relay." It also names the shared-guest-network case explicitly, and the same correction is mirrored in the relay's own header comment so the code and the ADR can't drift apart on it. That's the honest-limitations shape I was after. Nit — malformed Nit — Nit — build-surface disclosure. Fixed. README now states the kit builds from source during first boot and runs OpenDesign's pinned Process note — no test exercised the relay's peer logic. Substantially addressed: Two small follow-ups (neither blocking)1. But a default route whose gateway is Not exploitable — a TCP peer never presents as 2. The self-test covers the helpers, not the gate. It asserts 3 things: two
The scaffolding now exists, so these are cheap to add, and they'd pin the behaviour a reviewer currently has to re-derive manually. One mechanical item
Method: static diff review of |
Address relay route-selection and live verification feedback, and resolve the current main merge conflict. Co-authored-by: OpenCode Agent <agent@gsa.gov>
Re-review requestedAddressed the latest advisory follow-ups and merged current main into this branch. The new head is 039c7d5. Changes
Verification
Two independent AI-assisted adversarial reviews were run. The first identified route-selection and live-verifier gaps; those were fixed. The focused follow-up found no remaining findings. Human review remains required. RUN_ACQ=1 could not be run in this workspace because no ACQ sandbox runtime is available. The full make ci aggregate still reports unrelated baseline failures in intentionally vulnerable security-review fixtures and detects its generated Python cache as a kit; the relevant validation, test, formatting, and security commands above pass. Please re-review when available. |
|
AI-assisted re-review (OpenCode), advisory — needs a human to confirm before it drives a change. Reviewed head Both items from my last pass are fixed, and the live-verifier change fixes a real correctness bug I had not flagged. One finding, in the new route-selection code. The
|
basiliskus
left a comment
There was a problem hiding this comment.
Verdict: comment — 039c7d561d42c2cc19019b69d12cd11b870524f4
One non-blocking documentation issue:
integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md:254still usespgrep -f "apps/daemon/dist/cli.js" | head -n1. The updated verifier atscripts/verify:376-390explains that this can select the supervisor shell instead of the daemon and falsely pass because the shell inheritsOPENCODE_CONFIG. Please use the verifier's node-argv andOD_PORT=17456selection in the troubleshooting command too.
Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review
Prefer active gateway-bearing routes over point-to-point defaults so the relay keeps authorizing the ACQ forwarder. Document daemon-specific OpenCode config inspection. Co-authored-by: OpenCode Agent <agent@gsa.gov>
|
Re-review requested after addressing the outstanding blocker and the safe documentation follow-up. Changes:
Verification:
The complete Python validation/test gates could not run because this workspace lacks AI-assisted; human review required before merge. |
|
AI-assisted re-review (OpenCode), advisory — needs a human to confirm before it drives a change. Reviewed head My should-fix is fixed, and I probed the new selection logic harder than the self-test does. Resolved — a point-to-point route no longer out-ranks a real gatewayThe new logic classifies first and uses metric only within a class: const hasGateway = gateway !== '0.0.0.0';
const selectedHasGateway = selected.gateway !== '';
if ((!hasGateway && selectedHasGateway)
|| (hasGateway === selectedHasGateway && metric >= selected.metric)) continue;Self-test at this head, in a network-off I then extracted Every one is correct, including the three I expected to be the weak spots:
The The TROUBLESHOOTING change is a genuine improvementThe old command took UNVERIFIEDEverything requiring a live OpenDesign build: your offline verifier run, the daemon/relay port split, the OD Next POST probe, and NetBoth findings from my prior round are resolved, and the route-selection fix holds up under cases beyond its own tests. All 7 checks pass at No blocking findings remain from me. |
Summary
Adds an opt-in
opendesignACQ kit that runs OpenDesign headlessly inside the sandbox as a browser-accessible design workspace.The kit reuses the existing sandbox OpenCode CLI and keeps model credentials on the ACQ/OpenCode path, especially when composed with
usai-provider.Content Type
What Changed
integrations/isolation/acq-kits/opendesign/withhybrid/v1spec, installer, supervisor, verifier, README, troubleshooting, and design records.open-design-v0.24.0and verifies commit0d3a14c1df6dc5017f3cc3ef05b24558250c220b.24.21.0with SHA-256 verification and pnpm10.33.2.7456and publishes it via neutralpublishedPorts.10Gpersistent volume for OpenDesign source/state/tooling under/home/agent/.local/share/opendesign.agentId: "opencode",onboardingCompleted: true, and local telemetry disabled.USAI_API_KEYor any provider key into OpenDesign state.kits.yamland the ACQ kits README.Safety Checklist
Security Impact
This adds a local browser daemon to the sandbox attack surface.
0.0.0.0:7456inside the guest so ACQ/MSB create-time port publishing can reach it.OD_DISABLE_API_AUTH=1is intentional for this trusted, single-user ACQ browser kit posture, matching the approved direction during implementation.Adversarial Review Follow-Up
A sub-agent adversarial review flagged these issues and they were addressed:
chown -Rwith shallow ownership initialization.Verification
Local verification run in this agent sandbox:
Host verification performed by maintainer during development:
Residual host-side follow-up:
Rollback
Remove
integrations/isolation/acq-kits/opendesign/and delete theopendesignentries fromintegrations/isolation/acq-kits/kits.yamlandintegrations/isolation/acq-kits/README.md.AI Assistance
This PR was developed with AI assistance from OpenCode.