feat(kits): add paseo self-hosted web UI acq mixin kit - #312
Open
mogul wants to merge 9 commits into
Open
Conversation
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
marked this pull request as draft
August 11, 2026 17:33
Contributor
Author
|
Marking draft while I do more testing... |
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>
4 tasks
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
marked this pull request as ready for review
August 13, 2026 20:58
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an opt-in, backend-neutral
hybrid/v1acq mixin kit —paseo— that self-hosts the Paseo daemon and its bundled browser web UI inside anacqsandbox and publishes it to the host. Modeled on the siblingopenchamberkit, 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
6767(published to the host via neutralpublishedPorts).paseo-start.sh) installs@getpaseo/clion first boot (proxy + CA plumbing, sudo-or-per-user-prefix) and supervisespaseo daemon start --foreground --web-uiunder a respawn loop.~/.local/bin/opencodeis a ~25-line wrapper that shadows the realopencode. With args it passes through to the real opencode (required for interactive use and for Paseo's own opencode provider, which spawnsopencodeas a child); with no args itexecs the generic shim.~/paseo-agent-shimholds all agent-neutral kit logic: pin the worktree root, bounce the daemon on change, print connect info, and hold PID 1 onacq run. The wrapperexecs the shim so it inherits PID 1 and the hold works. Adding another agent = drop another thin wrapper reusing the shim unchanged.worktrees.root(no per-project option, and relative roots resolve againstPASEO_HOME). The shim pins it to<primary-project>/.paseo-worktreesand restarts the daemon to apply it — Paseo readsworktrees.rootonly at boot (verified: noSIGHUP/runtime reread), so a process bounce is required.PASEO_PASSWORD): the sandbox is the security boundary and the port is host loopback only.Commits
feat(kits): add paseo self-hosted web UI acq mixin kit— the kit.refactor(kits): split paseo entrypoint into thin wrapper + generic shim— factor the monolithicopencodewrapper into a thin agent-named wrapper over a reusable, agent-neutralpaseo-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/workspacemarker).Verification
validate-kits.py --strict→ all kits valid.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), akits.yamlregistry entry, and an acq-kits README row.Security impact
Introduces an opt-in kit that runs an unsecured daemon bound to
0.0.0.0inside 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 isregistry.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 thekits.yaml/README index entries.