Skip to content

feat(kits): add paseo self-hosted web UI acq mixin kit - #312

Open
mogul wants to merge 9 commits into
mainfrom
feat/paseo-acq-kit
Open

feat(kits): add paseo self-hosted web UI acq mixin kit#312
mogul wants to merge 9 commits into
mainfrom
feat/paseo-acq-kit

Conversation

@mogul

@mogul mogul commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in, backend-neutral hybrid/v1 acq mixin kit — paseo — that self-hosts the Paseo daemon and its bundled browser web UI inside an acq sandbox and publishes it to the host. Modeled on the sibling openchamber kit, but adapted to Paseo's architecture.

Only the self-hosted web UI is in scope (native desktop/mobile apps are out of scope). The kit is written to be reusable with other coding agents, minimizing OpenCode-specific coupling.

What it does

  • One daemon, one port. A single Paseo process serves the daemon API, the WebSocket, and the bundled web UI on container port 6767 (published to the host via neutral publishedPorts).
  • Startup script (paseo-start.sh) installs @getpaseo/cli on first boot (proxy + CA plumbing, sudo-or-per-user-prefix) and supervises paseo daemon start --foreground --web-ui under a respawn loop.
  • Thin agent-named wrapper over a generic shim (the entrypoint):
    • ~/.local/bin/opencode is a ~25-line wrapper that shadows the real opencode. With args it passes through to the real opencode (required for interactive use and for Paseo's own opencode provider, which spawns opencode as a child); with no args it execs the generic shim.
    • ~/paseo-agent-shim holds all agent-neutral kit logic: pin the worktree root, bounce the daemon on change, print connect info, and hold PID 1 on acq run. The wrapper execs the shim so it inherits PID 1 and the hold works. Adding another agent = drop another thin wrapper reusing the shim unchanged.
  • Worktrees. Paseo has a single global worktrees.root (no per-project option, and relative roots resolve against PASEO_HOME). The shim pins it to <primary-project>/.paseo-worktrees and restarts the daemon to apply it — Paseo reads worktrees.root only at boot (verified: no SIGHUP/runtime reread), so a process bounce is required.
  • No shared TUI session (accepted): Paseo launches its own agent CLIs as children, so there's no shared live session with a terminal TUI.
  • Unsecured daemon (no PASEO_PASSWORD): the sandbox is the security boundary and the port is host loopback only.

Commits

  1. feat(kits): add paseo self-hosted web UI acq mixin kit — the kit.
  2. refactor(kits): split paseo entrypoint into thin wrapper + generic shim — factor the monolithic opencode wrapper into a thin agent-named wrapper over a reusable, agent-neutral paseo-agent-shim; also corrects the generic-wrapper ADR (passthrough is not droppable while shadowing an agent binary; the primary-workspace signal is the entrypoint cwd, not the msb-only /var/lib/acq/workspace marker).

Verification

  • Offline gate: validate-kits.py --strict → all kits valid.
  • Live end-to-end via acq (RUN_ACQ=1 ./scripts/verify): all checks pass — kit translate+apply, port auto-publish, first-boot install, daemon /api/health + web UI on 6767, single supervisor, thin-wrapper-execs-shim + shim PID-1 hold + clean non-PID-1 exit, worktrees.root pinned under the project, daemon self-heal after a targeted kill. Re-run and confirmed after the wrapper/shim refactor.

Includes README, TROUBLESHOOTING, 5 ADRs, a scripts/verify (offline + live), a kits.yaml registry entry, and an acq-kits README row.

Security impact

Introduces an opt-in kit that runs an unsecured daemon bound to 0.0.0.0 inside the sandbox, published to a host loopback port only. Relies on the sandbox as the security boundary; documented in the README "Security" section. No secrets in the kit; only egress is registry.npmjs.org.

AI assistance

Authored with AI assistance (OpenCode).

Rollback

Revert this PR; the kit is self-contained under integrations/isolation/acq-kits/paseo/ plus the kits.yaml/README index entries.

Add an opt-in, backend-neutral hybrid/v1 acq mixin that self-hosts the
Paseo daemon + bundled browser web UI (https://github.com/getpaseo/paseo)
inside a sandbox on one port (6767) and publishes it to the host.

- Startup script installs @getpaseo/cli on first boot and supervises the
  daemon (paseo daemon start --foreground --web-ui) with a respawn loop.
- Agent-generic `opencode` wrapper: passthrough with args; no-arg path pins
  Paseo's global worktrees.root to <primary-project>/.paseo-worktrees and
  bounces the daemon (Paseo reads worktrees.root only at boot; no HUP), then
  holds PID 1 on `acq run` so the sandbox/UI stay alive. No shared TUI
  session (Paseo launches its own agent CLIs) — reusable with other agents.
- Neutral publishedPorts + background flag; no backend shortcuts/extras.
- Idempotent worktrees.root JSON merge helper; verify script (offline gate +
  live RUN_ACQ end-to-end); README, TROUBLESHOOTING, and 5 ADRs.
- Registry entry in kits.yaml and a row in acq-kits/README.md.

Live-verified end-to-end via acq (all checks pass).

Co-authored-by: OpenCode Agent <agent@gsa.gov>
@mogul
mogul requested a review from a team as a code owner August 11, 2026 17:32
@mogul
mogul marked this pull request as draft August 11, 2026 17:33
@mogul

mogul commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Marking draft while I do more testing...

mogul and others added 2 commits August 11, 2026 18:23
Factor the paseo kit's monolithic ~/.local/bin/opencode into a thin,
agent-named wrapper over a new agent-neutral shim (paseo-agent-shim), so
the kit's Paseo/PID-1/worktree logic is reusable by any agent:

- files/home/paseo-agent-shim: all agent-neutral kit work (pin worktrees.root
  to <cwd>/.paseo-worktrees, bounce daemon on change, print connect info,
  PID-1 discriminator + hold).
- files/home/.local/bin/opencode: now ~25 lines — passthrough to the real
  opencode with args (still required for interactive use AND Paseo's own
  opencode provider), else `exec sh $SHIM`. Execs (not calls) the shim so it
  inherits PID 1 on the acq run path, preserving the hold.
- spec.yaml drops the shim (0755) and updates the DESIGN notes.
- verify step 8 asserts the wrapper execs the shim and that the shim (not the
  wrapper) carries the $$-eq-1 check + hold_pid1.

Also correct the generic-wrapper ADR: passthrough is NOT droppable while
shadowing an agent binary (needed for Paseo's provider); the primary-workspace
signal is the entrypoint cwd (portable across backends), not the msb-only
/var/lib/acq/workspace marker. Docs, TROUBLESHOOTING, and kits.yaml updated.

Behavior-equivalent to the prior monolith; static checks + local exec/PID
harness pass. Adding another agent = drop another thin wrapper reusing the shim.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
…uncation)

The Paseo web UI loaded a black page on the msb backend: the small
length-delimited assets (index.html, manifest.json, css) succeeded, but the
large (~15 MB) chunked JS bundle failed with ERR_INCOMPLETE_CHUNKED_ENCODING
and follow-on connections were refused.

Root cause is upstream, not in this kit: msb's create-time published-port relay
(crates/network/lib/publisher.rs) never propagates the guest server's TCP
half-close, so a chunked response (no Content-Length) truncates while
length-delimited ones survive. The equivalent CLOSE_WAIT half-close handling
already exists on the egress relay (conn.rs, via microsandbox#1181/#1255) but was
never mirrored into the ingress/published-port relay. Discussed upstream on
microsandbox#1330.

Interim fix in this kit until #1330 lands:
- README, spec.yaml, TROUBLESHOOTING, and the agent shim now steer users to reach
  the UI via `acq ports <sandbox> --publish 6767:6767` (an OpenSSH `ssh -L`
  forward that terminates inside the guest and bypasses the publisher relay),
  instead of the auto-published ephemeral port.
- Add docs/decisions/large-assets-need-ssh-forward.md capturing the diagnosis,
  the conn.rs vs publisher.rs asymmetry, and the trigger to relax this once
  #1330 is fixed.
- scripts/verify: new step 5b fetches the large hashed JS bundle END TO END over
  the published HOST port (requesting browser-style compression so the daemon
  serves the failing chunked variant) and compares the decoded size to the
  in-guest baseline — so this black-page regression is caught rather than passing
  on the small files alone.

Verification (offline): validate-kits.py OK (6/6 kits); bash -n on verify and
the shim; sh -n on the POSIX shim; offline gate of scripts/verify passes 5/5.
Live host-side verify (RUN_ACQ=1) requires an acq/msb host and is not run here.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
mogul added 4 commits August 12, 2026 23:24
Cross-reference #318 from the decision record so
the deferred "relax once msb #1330 lands" work is discoverable from the ADR.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
Extend scripts/verify step 5b to fetch the large hashed JS bundle end-to-end
over BOTH host paths in one run, documenting the contrast:

- Path (A): the auto-published ephemeral port (msb create-time publisher relay).
  Informational — expected to truncate today (microsandbox#1330). If it instead
  delivers the full bundle, verify emits a loud WARN that #1330 may be fixed and
  the ssh -L workaround can be relaxed (points at #318).
- Path (B): `acq ports <sandbox> --publish <hp>:6767` (ssh -L, terminates inside
  the guest). This is the hard PASS/FAIL gate: it publishes a fixed host port,
  waits for /api/health, then asserts the full decoded bundle arrives.

Shared helpers fetch_host_bundle() (rc + code + decoded size, browser-style
compression so the daemon serves the chunked variant) and bundle_ok() (matches
the in-guest baseline, or clearly-big when the baseline is unknown). Path (B)
requires the acq driver; SKIPs cleanly under RUN_SBX.

Verification (offline): validate-kits.py OK (6/6); bash -n on verify; unit-tested
the bundle_ok/parse logic (baseline-match, no-baseline-big, short→fail,
curl-rc-fail→fail); offline gate still 5/5.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
…-bug fix

Host-side testing disproved the earlier "msb create-time relay truncates the
UI's large chunked bundle" theory that PR #312 briefly acted on. Fetching the
bundle raw (no client-side decode), 5x over each path, transfers are complete
and repeatable everywhere — in-guest, the auto-published port, and ssh -L
(identity 15,779,639 / gzip 3,428,240 / br 2,506,699 bytes, all rc=0). The
ERR_INCOMPLETE_CHUNKED_ENCODING / rc=61 I had seen was a CLIENT artifact: a
libz-only curl asked for `Accept-Encoding: br,zstd` --compressed and failed to
DECODE the precompressed body (CURLE_BAD_CONTENT_ENCODING), independent of the
network. The upstream comment on microsandbox#1330 has been retracted.

This reverts the unfounded parts and fixes the test that produced the false
signal:

- Back out the "steer to acq ports --publish / ssh -L to dodge a relay bug"
  framing from README.md, spec.yaml, TROUBLESHOOTING.md, and the agent shim
  (restored to their pre-#312-fix wording); remove
  docs/decisions/large-assets-need-ssh-forward.md (wrong premise).
- scripts/verify step 5b: fetch the large bundle with `Accept-Encoding: identity`
  and WITHOUT `--compressed`, comparing byte count to the in-guest identity
  baseline. This removes the client codec-support variable entirely, so a decode
  failure can never masquerade as truncation; only a genuine short read / reset
  fails the guard. (This is the §8.3 lesson: live validation caught a defect the
  harness itself introduced.)
- Add scripts/paseo-probe3: a read-only host diagnostic for the STILL-OPEN,
  browser-only black page (WebSocket upgrade over the host port, the injected
  same-origin daemon connection hint, service worker / manifest, Host/Origin
  acceptance, HTTP/2 vs 1.1) — curl shows assets arrive intact, so the cause is
  on a browser-only path, not asset delivery.

The real black-page cause is not yet identified and is NOT shown to be an msb
bug. Tracking follows in the issue update.

Verification (offline): validate-kits.py OK (6/6); bash -n on verify + probe3;
offline gate 5/5. Net diff vs the pre-fix baseline touches only scripts/.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
…e boot race)

Root cause of the persistent black page is now confirmed, and it is NOT an msb
bug: it is a first-load race that poisons the browser's HTTP cache. The web UI's
hashed assets are served `Cache-Control: immutable`; if a browser loads the page
during the boot window (after /api/health answers but before the ~15 MB hashed
bundle is fully serveable), Chrome caches a partial/broken br response and then
fails to decode it (ERR_CONTENT_DECODING_FAILED) on every later reload until the
cache is cleared. Over the host port the bytes are byte-identical to the on-disk
artifact and Brotli-decode correctly, and a disable-cache hard reload / Incognito
renders the app — proving stale client state, not transport or artifact.

Fix (kit-side; timing + guidance, no change to Paseo's caching):
- paseo-agent-shim: add ui_ready() — beyond /api/health, fetch the hashed entry
  bundle with `Accept-Encoding: br` (no decode) and require the received byte
  count to equal the daemon's on-disk .br artifact size (the bundle is chunked,
  so there's no Content-Length to compare). The shim now waits on ui_ready()
  before printing "READY — safe to open," and otherwise prints a WAIT notice plus
  the one-time remedy (disable-cache hard reload / Incognito).
- scripts/verify step 5b: add a browser-accurate check that fetches the bundle
  with `Accept-Encoding: br` and Brotli-decodes it via node zlib
  (brotliDecompressSync), asserting the decoded length equals the identity
  baseline. Models the exact ERR_CONTENT_DECODING_FAILED mode; independent of the
  host curl's codec support. (Keeps the identity end-to-end integrity check too.)
- TROUBLESHOOTING: add "Black page that persists on reload" with cause, the
  one-time remedy, and a scriptable bundle-readiness gate.
- Add docs/decisions/ui-readiness-gate-immutable-cache.md.
- paseo-probe3: bound the WebSocket handshake probe (--max-time; treat 101 as
  success) so the diagnostic can't hang, and add a python end-to-end WS handshake.

Verification (offline): ui_ready() logic exercised against a live local daemon
(on-disk .br 2,506,699 == br wire bytes -> ready); node brotliDecompressSync
decodes the served bundle to 15,779,639 and a truncated .br yields
"unexpected end of file" (negative test); sh -n/bash -n clean; validate-kits.py
6/6; offline gate 5/5. Live RUN_ACQ=1 host run still recommended.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
@mogul
mogul marked this pull request as ready for review August 13, 2026 20:58
mogul added 2 commits August 13, 2026 13:59
…eded)

The daemon defaults its cloud relay ON and dials wss://relay.paseo.sh on a
~30s retry loop. That host is not on this kit's egress allow-list (only
registry.npmjs.org is), so the sandbox proxy resets the TLS handshake mid-
flight and the daemon log fills with `relay_error` / `relay_control_disconnected`
(ECONNRESET) forever. The relay is unnecessary here: the host reaches the web UI
directly over the loopback-published port, not through Paseo's cloud relay.

Set PASEO_RELAY_ENABLED=false in the kit's guest env. It is read at daemon
config load (server/config.js resolveRelayConfig) and takes precedence over
persisted config, so it holds across daemon restarts/self-updates without
touching config.json — off by default for every new sandbox.

Note this does NOT affect web UI reachability: the daemon still binds
0.0.0.0:6767 and /api/health returns 200 throughout. A host-side "connection
refused" to the UI is a port-mapping issue, not the relay.

Add a TROUBLESHOOTING.md section documenting the relay symptom, cause, the
default fix, and a live workaround for pre-existing sandboxes.

Verified live: after disabling the relay and bouncing the daemon, relay_error
lines stop and http://127.0.0.1:6767/api/health stays 200. spec.yaml parses
(YAML OK) with the new quoted-string env value.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
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.

1 participant