Skip to content

feat(acq-kits): add OpenDesign browser kit - #438

Open
mogul wants to merge 13 commits into
mainfrom
plant-chipmunk
Open

mogul wants to merge 13 commits into
mainfrom
plant-chipmunk

Conversation

@mogul

@mogul mogul commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in opendesign ACQ 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

  • Skill
  • Prompt
  • Workflow
  • Agent instructions
  • Lesson learned
  • Integration / ACQ kit

What Changed

  • Added integrations/isolation/acq-kits/opendesign/ with hybrid/v1 spec, installer, supervisor, verifier, README, troubleshooting, and design records.
  • Pins OpenDesign to open-design-v0.24.0 and verifies commit 0d3a14c1df6dc5017f3cc3ef05b24558250c220b.
  • Installs kit-managed Node 24.21.0 with SHA-256 verification and pnpm 10.33.2.
  • Runs OpenDesign on guest port 7456 and publishes it via neutral publishedPorts.
  • Adds a 10G persistent volume for OpenDesign source/state/tooling under /home/agent/.local/share/opendesign.
  • Seeds OpenDesign app config for agentId: "opencode", onboardingCompleted: true, and local telemetry disabled.
  • Does not seed a default model.
  • Does not copy USAI_API_KEY or any provider key into OpenDesign state.
  • Registers the kit in kits.yaml and the ACQ kits README.

Safety Checklist

  • No secrets, tokens, credentials, or private keys
  • No PII, CUI, customer data, or sensitive info
  • No internal URLs or system details
  • Examples use placeholders
  • Credentials remain with ACQ/OpenCode, not persisted by the kit

Security Impact

This adds a local browser daemon to the sandbox attack surface.

  • OpenDesign binds 0.0.0.0:7456 inside the guest so ACQ/MSB create-time port publishing can reach it.
  • The host side is expected to be loopback-only through ACQ port publishing.
  • OD_DISABLE_API_AUTH=1 is intentional for this trusted, single-user ACQ browser kit posture, matching the approved direction during implementation.
  • Documentation explicitly warns not to forward the mapped port or use it on an untrusted multi-user host.
  • Live verification now checks both in-guest daemon/UI health and host-loopback reachability.

Adversarial Review Follow-Up

A sub-agent adversarial review flagged these issues and they were addressed:

  • Pinned pnpm store to the OpenDesign persistent volume.
  • Replaced recursive startup chown -R with shallow ownership initialization.
  • Made verifier output explicit about skipped schema validation.
  • Strengthened live credential check to look for the injected secret value, not just the variable name.
  • Kept auth-disabled posture as an intentional accepted risk documented in README and ADR.

Verification

Local verification run in this agent sandbox:

integrations/isolation/acq-kits/opendesign/scripts/verify
Result: 15 passed, 0 failed, 1 skipped
Note: schema validator skipped here because this sandbox lacks jsonschema/pyyaml.
bash -n integrations/isolation/acq-kits/opendesign/scripts/verify
sh -n integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh
sh -n integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh
git diff --check
Result: passed

Host verification performed by maintainer during development:

python integrations/isolation/acq-kits/validate-kits.py --strict
Result: all 8 acq-kits valid, including opendesign
integrations/isolation/acq-kits/opendesign/scripts/verify
Result: 13 passed, 0 failed before later verifier hardening
KEEP=1 RUN_ACQ=1 READY_TIMEOUT=1200 integrations/isolation/acq-kits/opendesign/scripts/verify
Observed:
- volume ownership corrected to agent:agent
- OpenDesign source install completed
- Next/OpenDesign web build completed after increasing Node heap
- daemon/UI reachable inside sandbox
- OpenCode present on PATH
- app-config seeded correctly:
  {"agentId":"opencode","onboardingCompleted":true,"telemetry":{"metrics":false,"content":false,"artifactManifest":false}}
- guest port 7456 reported as mapped by `acq ports`

Residual host-side follow-up:

A browser attempt against http://127.0.0.1:7456 initially returned ERR_CONNECTION_REFUSED despite `acq ports` reporting a mapping. The verifier was updated to curl the reported host-loopback URL so this cannot be missed in the next live run.

Rollback

Remove integrations/isolation/acq-kits/opendesign/ and delete the opendesign entries from integrations/isolation/acq-kits/kits.yaml and integrations/isolation/acq-kits/README.md.

AI Assistance

This PR was developed with AI assistance from OpenCode.

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>
@mogul
mogul requested a review from a team as a code owner September 23, 2026 01:04
…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>
@mogul

mogul commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Live-sandbox findings: two defects, both reproduced and fixed

Ran this kit in an msb sandbox paired with usai-provider. The UI came up and browsed fine, but every prompt failed after ~2m43s with "This task failed to run", and the Export logs button returned 403. Two independent causes.

1. OpenCode never reads the usai-provider config, so every run fails

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 vars, so it reads the empty stub it auto-creates at $OD_DATA_DIR/sandbox/config/opencode/opencode.jsonc and never sees ~/.config/opencode/opencode.jsonc. With no usai provider defined it falls back to its own hosted gateway, which this kit's egress allowlist blocks:

providerID=opencode modelID=big-pickle
url: https://opencode.ai/zen/v1/chat/completions
AI_APICallError: Cannot connect to API

The run record agrees: status=failed, failureDetail=stream_error, 163s wall time. Confirmed the mechanism directly — opencode models under the sandbox env lists only opencode/*; under the host env it lists usai/*.

Note there is no UI workaround: upstream's Settings → Local CLI allowlist for the opencode agent accepts only OPENCODE_BIN (app-config.ts), not a config path or an API key.

Fix: startup exports OPENCODE_CONFIG, OpenCode's documented absolute-path override, which survives the HOME/XDG rewrite. Names a config path only — USAI_API_KEY stays in the environment and is resolved by that config's own {env:USAI_API_KEY} substitution, preserving acq-owned-byok.md.

2. The 0.0.0.0 bind makes 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 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 host request and 403s exactly three routes while the rest of the UI works:

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>
@mogul

mogul commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in bd2beef after the latest host run.

Changes:

  • Force the relay upstream socket to use localAddress: 127.0.0.1 when connecting to the loopback-bound OpenDesign daemon, so peer-loopback checks do not depend on OS source-address selection.
  • Strengthen live verification diagnostics: it now prints host /api/daemon/status, checks the guarded OD Next rollout route, and prints guarded-route response bodies when export/rollout return non-200.

Local verification after the patch:

  • node --check opendesign-relay.mjs: pass
  • bash -n scripts/verify: pass
  • sh -n opendesign-start.sh: pass
  • sh -n opendesign-install.sh: pass
  • git diff --check: pass
  • offline scripts/verify: 25 passed, 0 failed, 1 skipped in this sandbox (schema deps absent locally)

Please rerun host live verification from the updated branch:

RUN_ACQ=1 scripts/verify

If 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.

mogul and others added 2 commits September 23, 2026 06:05
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>
@mogul

mogul commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial follow-up pushed in 1fd248f.

Findings addressed:

  • Relay exposure: opendesign-relay.mjs now denies clients unless the peer is loopback, the default IPv4 gateway, or an explicit OPENDESIGN_RELAY_ALLOWED_PEERS entry. It still originates upstream daemon connections from 127.0.0.1.
  • Verification semantics: scripts/verify now requires validate-kits.py --strict by default; ALLOW_PARTIAL_VERIFY=1 is the explicit smoke-test escape hatch when Python deps are unavailable.
  • Stale ADR: source-install-headless-daemon.md now shows the loopback daemon bind plus relay instead of the rejected 0.0.0.0 command.
  • Troubleshooting leakage: docs now warn not to paste full logs/diagnostics/OD_DATA_DIR files publicly and narrow several examples to status fields only.
  • Troubleshooting command bug: fixed the Node ABI check command to be architecture-neutral and correctly quoted.
  • Credential-path caveat: scripts/verify now comments that its synthetic USAI_API_KEY is for negative persistence testing; the documented operational path remains acq secret set-custom.

Accepted risk left explicit:

  • OD_DISABLE_API_AUTH=1 remains intentional for this trusted, single-user local UI kit, but the relay is now narrower than the earlier guest-network surface.

Verification in this sandbox:

  • ALLOW_PARTIAL_VERIFY=1 integrations/isolation/acq-kits/opendesign/scripts/verify: 26 passed, 0 failed, 1 skipped
  • node --check integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs: pass
  • bash -n integrations/isolation/acq-kits/opendesign/scripts/verify: pass
  • sh -n integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh: pass
  • sh -n integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh: pass
  • git diff --check: pass

Full schema validation and live RUN_ACQ=1 should be rerun from the host because this sandbox lacks the Python validator deps and cannot create nested ACQ sandboxes.

mogul and others added 2 commits September 23, 2026 07:28
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>
@mogul

mogul commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in e4a90a4 for the latest host verification failure.

The decisive clue was the host /api/daemon/status body:

"bindHost":"0.0.0.0"

That means the daemon was still inheriting a backend-provided OD_BIND_HOST=0.0.0.0, so ACQ reached the daemon directly and guarded routes still saw a non-loopback peer. The relay could not help because the daemon was the listener on the published path.

Fix:

  • opendesign-start.sh no longer inherits OD_BIND_HOST for the daemon.
  • It forces OD_BIND_HOST=${OPENDESIGN_DAEMON_BIND_HOST:-127.0.0.1}.
  • OPENDESIGN_DAEMON_BIND_HOST is now the explicit test-only override.
  • scripts/verify checks this offline.
  • Troubleshooting/ADR docs explain why backend-provided OD_BIND_HOST is intentionally ignored.

Local verification:

  • ALLOW_PARTIAL_VERIFY=1 integrations/isolation/acq-kits/opendesign/scripts/verify: 26 passed, 0 failed, 1 skipped
  • node --check opendesign-relay.mjs: pass
  • bash -n scripts/verify: pass
  • sh -n opendesign-start.sh: pass
  • sh -n opendesign-install.sh: pass
  • git diff --check: pass

Please rerun host live verification from the updated branch. The daemon-status line should now report "bindHost":"127.0.0.1"; if guarded routes still fail, the relay log tail now prints accepted/denied peers for the host request path.

@wz-gsa

wz-gsa commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

AI-assisted adversarial review (OpenCode), advisory — needs a human to confirm before it drives a change. Reviewed at head e4a90a4, main at 5d1a877. Independent capacity — I'm reviewing alongside the other session, not duplicating it.

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

opendesign-relay.mjs denies non-forwarder peers, and I verified the logic works as written — transcribed normalizePeerAddress + defaultIpv4Gateway + allowedPeerAddresses and ran them:

parsed default gateway -> 192.168.65.1
allowlist -> 127.0.0.1, ::1, 0:0:0:0:0:0:0:1, 192.168.65.1
peer 127.0.0.1         -> ACCEPTED
peer ::ffff:127.0.0.1  -> ACCEPTED   (v4-mapped normalisation works)
peer 192.168.65.1      -> ACCEPTED   (gateway = the host forwarder)
peer 192.168.65.42     -> denied     (another guest-network peer)
unreadable /proc/net/route -> gateway absent -> forwarder DENIED (fails closed)

That's a real narrowing over the 0.0.0.0 draft. But the ADR's Consequences section states it as the exposure boundary — "the relay accepts only loopback, the default gateway peer, and optional OPENDESIGN_RELAY_ALLOWED_PEERS entries" — and that claim doesn't survive the sandbox's own threat model. The agent inside has passwordless sudo (acq ADR-0005/0011, and quickstart's ADR-0030 states the consequence explicitly: "inside the guest, root-ownership is not a trust boundary against the agent"). A prompt-injected agent can pkill -f supervisor:opendesign-relay, bind 0.0.0.0:7456 itself, and accept any peer — or just set OPENDESIGN_RELAY_ALLOWED_PEERS before the supervisor's next restart.

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 BACKEND=docker the guest network is a shared VM — other sandboxes on the same host are on it. Combined with OD_DISABLE_API_AUTH=1, anything that reaches guest :7456 drives OpenDesign and the agent CLIs it launches.

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 BACKEND=docker. (SANDBOX.md in quickstart carries exactly this kind of "honest limitations" note for NET=filtered, for the same reason: the control runs inside the thing it's constraining.)

Nit: a malformed PROXY_CA_CERT_B64 silently produces a broken CA bundle

opendesign-install.sh:97:

[ -n "${PROXY_CA_CERT_B64:-}" ] && printf %s "$PROXY_CA_CERT_B64" | base64 -d >>"$ca_bundle" 2>/dev/null || true

base64 -d writes decoded bytes as it goes and then fails, so a truncated or corrupt value appends partial garbage to $ca_bundle. The next two lines append the system CAs and set NODE_EXTRA_CA_CERTS because [ -s ] is now true. Node then rejects the whole bundle, and every fetch fails with a TLS error that points at the registry rather than at the CA var. 2>/dev/null || true hides the only signal.

Decode to a temp file, check the exit status, and only append on success — otherwise log that the CA var was unusable. Same degraded-path rule the repo applies elsewhere: "could not use it" should be distinguishable from "used it".

Nit: publishableAddresses() binds every non-internal address, not just the published path

The relay binds all non-internal IPv4/IPv6 addresses and rescans every 10s for new ones. Only the interface acq's publishing actually dials needs a listener. On a multi-homed guest this is broader than the design requires, and the peer check is the only thing standing behind the extra binds. Not exploitable on its own given that check — worth narrowing to the interface carrying the default route, which the relay already computes for the allowlist.

Nit: this kit is a large step up in build surface, unlike its siblings

Every other UI kit installs one pinned published artifact — @getpaseo/cli@<ver>, @openchamber/web, a digest-pinned release tarball. This one runs pnpm install --frozen-lockfile + pnpm bootstrap + a Next.js build over OpenDesign's whole monorepo at every sandbox start, which executes install lifecycle scripts across its entire transitive tree.

To be fair to the design: --frozen-lockfile at a pinned commit means the dependency tree is pinned with integrity hashes, and everything runs as uid 1000 inside the sandbox, which is the boundary. So I'm not calling it a vulnerability. But it's worth stating in the kit README that this kit builds from source and therefore executes third-party lifecycle scripts, because it's the kit a reviewer would most want to know that about, and --ignore-scripts isn't available here the way it is for the siblings' single-package installs.

What I checked that holds

  • The commit pin is verified before any build step. Order is git fetch → checkout --detach FETCH_HEAD → compare git rev-parse HEAD to OPENDESIGN_COMMIT → fail_soft → then pnpm install (opendesign-install.sh:112-130). A moved open-design-v0.24.0 tag can't get code executed. I also checked whether checkout alone could run a hook from the fetched repo — it can't: .git/hooks isn't versioned and git ignores repo-local config outside .git/config (verified with a local bare-repo fixture carrying a .githooks/post-checkout).
  • Node is SHA-256 pinned per arch, verified before extraction, mismatch deletes the tarball and fail_softs — and an unsupported arch refuses rather than falling through.
  • The set -eu + [ … ] && export block is safe. opendesign-install.sh:93-99 is the classic false-abort shape, so I re-implemented it under /bin/sh with set -eu and every proxy/CA variable unset: execution reaches the end with rc 0. A top-level failing && doesn't abort. (Worth stating because the same idiom is a real bug when it's the last statement of a function called from if !.)
  • OD_BIND_HOST is not inherited from the backend — opendesign-start.sh:25 forces ${OPENDESIGN_DAEMON_BIND_HOST:-127.0.0.1} precisely because a backend default of 0.0.0.0 would re-break the peer-gated routes. The comment says why.
  • scripts/verify:406-427 regression-gates the actual bug, asserting HTTP 200 on /api/strategies/od-next/rollout and /api/diagnostics/export through the host path, and dumping the relay log on failure. That's the right shape — it pins the symptom, not the implementation.
  • agentContext is protective, not permissive: "Do not paste provider keys into chat", and it names the relay log first for any 403. No instruction that would weaken a control.
  • OPENCODE_CONFIG names a path only; no key material is copied, and the rationale (OD_SANDBOX_MODE rewrites HOME/XDG so the usai-provider config would otherwise be invisible) is accurate and well documented.
  • Egress is 6 exact hosts, no wildcards, no 0.0.0.0/0, no 169.254.169.254, no host path mounted. Relay file mode 0644 is correct — it's run via node <path>, never exec'd.
  • bin/injection-scan.sh flagged one hit — the PROXY_CA_CERT_B64 | base64 -d line — which I inspected and it's the legitimate CA plumbing above, not obfuscation.

Process note

No test or fixture exercises the relay's peer-allowlist logic. scripts/verify needs RUN_ACQ=1 and a live sandbox, so in CI the ~200 lines of security-relevant JS are unexecuted. This is the same gap a panel raised on #416 (item 6). The peer normaliser and gateway parser are pure functions over strings — cheap to unit-test, and the parts most worth pinning.


Method: static analysis at e4a90a4 plus transcription-and-run of the relay's peer logic, the set -eu proxy block, and a local git-hook fixture. No kit was executed and no sandbox was created. UNVERIFIED: everything that needs a live OpenDesign — the 403 behaviour, the build succeeding, and whether the relay's all-interface bind matters on a real msb guest. A 5-voter pr_review panel was attempted and all five voters errored (JSON parse failures, producerVersion 8.81.2), so this review is single-reviewer; I'm flagging that rather than implying panel agreement.

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>
@mogul

mogul commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in 9d698a3 and ready for re-review.

Changes since the last review:

  • Fixed the live verifier readiness probe to check the in-guest OpenDesign daemon on 127.0.0.1:17456 after the relay/daemon port split. The prior host run proved the daemon and host-published path were healthy, but the readiness loop was still probing 7456 in-guest.
  • Hardened PROXY_CA_CERT_B64 handling so invalid base64 is decoded to a temp file first, logged, and ignored instead of appending partial garbage to NODE_EXTRA_CA_CERTS.
  • Narrowed the relay to bind only the default-route guest interface rather than every non-internal address.
  • Added opendesign-relay.mjs --self-test and wired it into scripts/verify so peer normalization and route parsing run in offline validation.
  • Updated README, troubleshooting, and ADR docs to state that the relay peer filter is defense-in-depth for accidental guest-network reachability, not a boundary against hostile code already running inside the sandbox.
  • Removed the fixed post-hoc publish example because that path may dial guest loopback 7456 and bypass the split-port relay design.
  • Documented that this kit builds from source and runs pinned lifecycle/build scripts inside the sandbox.

Verification:

bash -n integrations/isolation/acq-kits/opendesign/scripts/verify
sh -n integrations/isolation/acq-kits/opendesign/files/home/opendesign-install.sh
sh -n integrations/isolation/acq-kits/opendesign/files/home/opendesign-start.sh
node --check integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs
node integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs --self-test
python3 scripts/scan_unsafe_shell.py .
git diff --check
Result: passed
ALLOW_PARTIAL_VERIFY=1 integrations/isolation/acq-kits/opendesign/scripts/verify
Result: 30 passed, 0 failed, 1 skipped
Skip: validate-kits.py schema validation needs jsonschema + pyyaml in this sandbox.

Host live verification from the sandbox-capable host was rerun with ALLOW_PARTIAL_VERIFY=1 RUN_ACQ=1 integrations/isolation/acq-kits/opendesign/scripts/verify after the readiness fix:

Result: 48 passed, 0 failed, 1 skipped
Confirmed: in-guest daemon/UI health, host-loopback published URL, OD Next rollout HTTP 200, diagnostics export HTTP 200, OPENCODE_CONFIG propagation, and 15 usai/* models visible under OpenDesign sandbox-mode env.
Skip: schema validation dependency gap only.

Please re-review the OpenDesign kit changes when available.

AI-assisted; human review required before merge.

@mogul
mogul marked this pull request as ready for review September 24, 2026 05:17
mogul and others added 3 commits September 24, 2026 05:21
Co-authored-by: OpenCode Agent <agent@gsa.gov>
Co-authored-by: OpenCode Agent <agent@gsa.gov>
@wz-gsa

wz-gsa commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

AI-assisted re-review (OpenCode), advisory — needs a human to confirm before it drives a change. Re-reviewed 9d698a3 and current head 84206ee against my Sep 23 review at e4a90a4. Apologies for the lag on this one; you asked for re-review on Sep 24 and it sat.

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 finding

Should-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 PROXY_CA_CERT_B64 silently appends garbage. Fixed exactly as specified: decode to $STATE_HOME/proxy-ca.pem.tmp, check the exit status, cat into the bundle only on success, else log "WARNING: ignoring invalid PROXY_CA_CERT_B64; could not decode proxy CA", and clean up the temp file either way. "Could not use it" is now distinguishable from "used it."

Nit — publishableAddresses() binds every non-internal address. Fixed. It now resolves the default-route interface first and filters name !== route.iface, so only the interface acq's publishing actually dials gets a listener. Bonus: link-local IPv6 is now skipped with a stated reason (avoids a recurring EINVAL), which I hadn't asked for but is right.

Nit — build-surface disclosure. Fixed. README now states the kit builds from source during first boot and runs OpenDesign's pinned pnpm install --frozen-lockfile, package lifecycle scripts, bootstrap, and Next.js build in-sandbox as agent, while noting the pins — the "larger first-boot build surface than sibling kits" framing is accurate and appropriately non-alarmist.

Process note — no test exercised the relay's peer logic. Substantially addressed: --self-test added and wired into scripts/verify with proper ok/bad/skip handling. I ran it at the current head: relay self-test passed, exit 0.

Two small follow-ups (neither blocking)

1. 0.0.0.0 gateway enters the peer allowlist as a dead entry. I transcribed the new route parser and probed its edges. The fail-closed path is correct — no default route gives iface: '', so publishableAddresses() returns [] and the relay binds nothing:

route table with no default route -> {"iface":"","gateway":""} -> binds nothing (fails closed)

But a default route whose gateway is 0.0.0.0 (point-to-point links) parses to the string "0.0.0.0", which is truthy and therefore survives .filter(Boolean) into allowedPeerAddresses():

'Iface\tDestination\tGateway\neth0\t00000000\t00000000\n' -> {"iface":"eth0","gateway":"0.0.0.0"}

Not exploitable — a TCP peer never presents as 0.0.0.0, so this adds a non-matchable allowlist entry rather than opening access. Cosmetic; a gateway === '0.0.0.0' ? '' : gateway guard in parseDefaultIpv4Route would drop it.

2. The self-test covers the helpers, not the gate. It asserts 3 things: two normalizePeerAddress cases and the parseDefaultIpv4Route happy path. Still unexercised, all pure functions over strings:

  • allowedPeerAddresses() — the actual decision
  • relayConnection()'s deny path
  • the no-default-route fail-closed case (the behaviour that matters most, and the one I had to transcribe by hand to confirm)
  • OPENDESIGN_RELAY_ALLOWED_PEERS parsing/normalisation
  • publishableAddresses() iface filtering — i.e. the finding-3 fix itself

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

integrations/isolation/acq-kits/kits.yaml conflicts against current main (opendesign's catalog entry against kits added since). acq-kits/README.md auto-merges clean. mergeStateStatus is DIRTY for that reason.


Method: static diff review of e4a90a4..9d698a3 plus transcription-and-run of the current head's route parser and peer-allowlist logic against adversarial inputs, and an actual execution of --self-test. No kit was executed and no sandbox was created. UNVERIFIED: everything needing a live OpenDesign — the 48-pass host run you reported, the build succeeding, the daemon/relay port split end-to-end. I'm taking those on your reported evidence, not re-confirming them.

Address relay route-selection and live verification feedback, and resolve the current main merge conflict.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
@mogul

mogul commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Re-review requested

Addressed the latest advisory follow-ups and merged current main into this branch. The new head is 039c7d5.

Changes

  • Relay route selection now ignores inactive defaults, chooses the lowest-metric active default route, and omits 0.0.0.0 point-to-point gateways from its peer allowlist.
  • Expanded the relay offline self-test to cover multi-route selection, no-route fail-closed behavior, point-to-point gateways, peer allow/deny decisions, configured peer normalization, and default-interface-only bind selection.
  • The live verifier now validates and uses the OPENCODE_CONFIG path read from the actual OpenDesign daemon environment, rather than a separate shell default.
  • The OD Next regression probe now sends the browser route required POST request with JSON content.
  • Resolved the kits.yaml conflict while retaining both the OpenDesign and incoming catalog entries.

Verification

  • OpenDesign offline verifier: 32 passed, 0 failed, 0 skipped.
  • Strict kit validator: all 11 kits valid.
  • make validate: passed.
  • make generate-check: passed.
  • make test: 407 passed.
  • make test-kits: passed.
  • Unsafe-shell scan: 0 errors, 0 warnings.
  • Node syntax check, relay self-test, shell syntax checks, and git diff check: passed.

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.

@wz-gsa

wz-gsa commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

AI-assisted re-review (OpenCode), advisory — needs a human to confirm before it drives a change. Reviewed head 039c7d56 against 84206ee.

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 0.0.0.0 finding is fixed, and the self-test now covers the gate

gateway === '0.0.0.0' ? '' : gateway with the reason stated inline, plus an assertion that the unspecified gateway cannot enter the allowlist. Ran it: relay self-test passed, exit 0.

The coverage gap I raised is closed properly. allowedPeerAddresses and publishableAddresses now take injectable parameters, so the self-test exercises the gate itself rather than only the helpers — peer allow/deny, configured-peer normalization, the no-route fail-closed case, default-interface-only bind selection, and multi-route selection. The new isAllowedPeer is what relayConnection calls, so the tested path is the production path rather than a parallel reimplementation.

The live-verifier change fixes a bug worth naming

The old env-passing reused OPENCODE_CONFIG as both the inherited variable and the probe input, so the fallback "${OPENCODE_CONFIG:-$HOME/.config/opencode/opencode.jsonc}" could silently check a different file than the one the daemon actually carries — the exact wrong-target-read-that-reads-as-a-pass failure the daemon_pid_cmd comment above it warns about. Passing the daemon-read path as a distinct OPENCODE_CONFIG_PATH closes that.

I checked the interpolation for injection, since $opencode_config comes from a /proc/<pid>/environ read and lands in a command line. It is safe: env "OPENCODE_CONFIG_PATH=$opencode_config" makes the value one argv element, and the inner sh -c only ever dereferences "$OPENCODE_CONFIG_PATH", never re-evaluates it. Confirmed with a hostile value (/tmp/x; touch /tmp/PWNED) — no execution, the string stayed a path.

Should-fix: a point-to-point default route can out-rank a real gateway

parseDefaultIpv4Route selects on lowest metric, then blanks the gateway for a point-to-point route. Those two rules interact: a p2p route with a better metric wins the selection and then contributes no peer, even when a real gateway route exists.

I transcribed the new parser and probed cases the self-test does not cover:

only p2p route available                  -> iface="ppp0" gw=""
p2p metric 5 beats real gw metric 100     -> iface="ppp0" gw=""      <-- real 192.168.65.1 route discarded

The self-test's multipleRoutes case covers metric ordering among routes that all have gateways, and pointToPoint covers a p2p route in isolation — but not the mixed case. Effect is fail-closed (the forwarder's peer is absent, so a legitimate client is denied rather than an illegitimate one allowed), so this is availability, not exposure. Still worth deciding deliberately: either prefer a gateway-bearing route when one exists at any metric, or state that p2p-wins is intended.

Three smaller observations from the same probe, none blocking:

RTF_UP clear (flags 0002)      -> iface="" gw=""              correct, fails closed
missing Metric column          -> iface="" gw=""              correct, fails closed
negative metric (-1)           -> iface="eth0" gw="192.168.65.1"   accepted
gateway hex too short (41A8C0) -> iface="eth0" gw="192.168.65"     accepted, 3-octet address

The two fail-closed cases are right. Number.isFinite(-1) is true so a negative metric is accepted — harmless, since the kernel does not emit one and it only affects ordering. The short-hex case is the only one that produces a malformed value rather than rejecting: fields[2].match(/../g) accepts any even-length hex, so a 6-digit field yields 192.168.65. That cannot come from the kernel either; a hex.length === 4 check would make it structural rather than relying on the source being well-formed.

Also confirmed the real /proc/net/route format parses correctly — the header is space-padded, not tab-separated as the self-test fixtures are, and split(/\s+/) handles both.


Method: diffed 84206ee..039c7d56; ran --self-test at the new head; transcribed the new parseDefaultIpv4Route and probed 10 inputs (real /proc spacing, metric 0, tied metrics, RTF_UP clear, missing column, negative metric, short hex, p2p alone, p2p-beats-gateway, blank line); tested the env-passing path with an injection payload.

UNVERIFIED: everything needing a live OpenDesign — your 32-pass offline verifier run, the build, the daemon/relay port split, the OD Next POST probe returning 200, and RUN_ACQ=1 (which you note could not run in your workspace either). Taken on your evidence, not re-confirmed.

@basiliskus basiliskus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: comment — 039c7d561d42c2cc19019b69d12cd11b870524f4

One non-blocking documentation issue:

  • integrations/isolation/acq-kits/opendesign/TROUBLESHOOTING.md:254 still uses pgrep -f "apps/daemon/dist/cli.js" | head -n1. The updated verifier at scripts/verify:376-390 explains that this can select the supervisor shell instead of the daemon and falsely pass because the shell inherits OPENCODE_CONFIG. Please use the verifier's node-argv and OD_PORT=17456 selection 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>
@mogul

mogul commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Re-review requested after addressing the outstanding blocker and the safe documentation follow-up.

Changes:

  • Prefer an active gateway-bearing default route over a lower-metric point-to-point route, so the relay retains the ACQ forwarder peer when both route types exist.
  • Reject malformed route gateway encodings and cover both cases in the relay self-test.
  • Update troubleshooting to identify the daemon by Node argv plus OD_PORT=17456, matching the verifier and avoiding supervisor-process false passes.

Verification:

  • node integrations/isolation/acq-kits/opendesign/files/home/opendesign-relay.mjs --self-test passed.
  • Node/shell syntax checks and git diff --check passed.
  • ALLOW_PARTIAL_VERIFY=1 integrations/isolation/acq-kits/opendesign/scripts/verify: 31 passed, 0 failed, 1 skipped (jsonschema/pyyaml unavailable in this workspace).
  • make test-kits: passed.
  • Markdown lint for the updated troubleshooting document: passed.

The complete Python validation/test gates could not run because this workspace lacks python, jsonschema, pyyaml, and pytest; no dependencies were installed. Live RUN_ACQ=1 validation remains unavailable because no ACQ runtime is present.

AI-assisted; human review required before merge.

@wz-gsa

wz-gsa commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

AI-assisted re-review (OpenCode), advisory — needs a human to confirm before it drives a change. Reviewed head 58247263b against my prior round at 039c7d56.

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 gateway

The 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 node:22-slim container:

$ node opendesign-relay.mjs --self-test
relay self-test passed    (exit 0)

I then extracted parseDefaultIpv4Route and drove it with cases the self-test does not cover, because the two added assertions only test the one ordering:

two gw routes, ppp lowest metric   iface=eth0    gw=192.168.65.1
ppp AFTER gw (order flipped)       iface=eth0    gw=192.168.65.1
ppp only                           iface=ppp0    gw=<none>
two ppp, lower metric wins         iface=ppp1    gw=<none>
two gw, lower metric wins          iface=eth1    gw=192.168.100.1
gw route inactive (flags no UP)    iface=ppp0    gw=<none>
malformed 6-hex gw                 iface=<none>  gw=<none>
negative metric rejected           iface=<none>  gw=<none>
metric 0 gw route                  iface=eth0    gw=192.168.65.1
ppp metric0 vs gw metric0          iface=eth0    gw=192.168.65.1

Every one is correct, including the three I expected to be the weak spots:

  • Order independence. Preferring the gateway route regardless of which line comes first is the property that actually matters, and ppp AFTER gw confirms it. A naive fix would have been order-sensitive.
  • metric 0. The old metric >= selected.metric against an Infinity seed made 0 fine, but the new metric < 0 guard plus class comparison keeps it working. ppp metric0 vs gw metric0 resolves to the gateway, which is the tie-break you want.
  • ppp only still yields iface=ppp0, gw=''. This is the case worth being explicit about: the relay still binds and publishes on a point-to-point interface, it just has no gateway peer to authorize. That is the correct behavior — the comment in the diff says exactly this — and it means the allow-list falls back to loopback plus whatever OPENDESIGN_RELAY_ALLOWED_PEERS configures, not to open.

The /^[0-9a-fA-F]{8}$/ guard also removes the old hex.match null path, so the malformed-gateway case now rejects the route instead of relying on a downstream continue. The added assertion for it is a real regression test.

The TROUBLESHOOTING change is a genuine improvement

The old command took pgrep -f "apps/daemon/dist/cli.js" | head -n1, which matches any process whose command line contains the string — including the sh -c wrapper running the probe itself. The replacement anchors on ^[^ ]*node and then confirms OD_PORT=17456 from /proc/$p/environ before accepting the pid. That turns "first thing that looked like the daemon" into "the daemon serving the port we care about," and the [ -r ... ] || continue keeps it from dying on a pid it cannot read.


UNVERIFIED

Everything requiring a live OpenDesign build: your offline verifier run, the daemon/relay port split, the OD Next POST probe, and RUN_ACQ=1. I ran the relay's own self-test and my own parseDefaultIpv4Route harness in a container; I did not stand up OpenDesign. The TROUBLESHOOTING command above is reviewed as shell logic, not executed against a running daemon.


Net

Both 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 58247263b and the PR is mergeable.

No blocking findings remain from me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants