Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
554d086
fix(paseo): open real opencode outside PID 1
wz-gsa Oct 29, 2026
aa6d94f
fix(paseo): reject wrapper aliases and clarify entrypoint probe
wz-gsa Oct 29, 2026
3534cff
fix(paseo): fail verification on stale worktree roots
wz-gsa Oct 29, 2026
7ed9dba
fix(paseo): verify the exact worktrees root field
wz-gsa Oct 29, 2026
384e37d
fix(paseo): avoid running the real TUI in verification
wz-gsa Oct 29, 2026
139f15c
docs(paseo): distinguish startup seeding from PID-1 hold
wz-gsa Oct 29, 2026
37b1249
fix(paseo): qualify detached verification outcomes
wz-gsa Oct 29, 2026
cbd51a7
fix(paseo): qualify preserved roots and skipped checks
wz-gsa Oct 29, 2026
594414e
fix(paseo): return unverified when live checks are skipped
wz-gsa Oct 29, 2026
173ad44
fix(paseo): distinguish guest probe failure from bad config
wz-gsa Oct 29, 2026
8bbe431
fix(paseo): validate root encoding without rejecting spaces
wz-gsa Oct 29, 2026
7b1bb32
fix(paseo): compare registered project paths literally
wz-gsa Oct 29, 2026
01734d1
fix(paseo): dispatch PID-1 shim before binary discovery
wz-gsa Oct 29, 2026
96e853a
fix(paseo): avoid hardlink recursion and compare guest project paths
wz-gsa Oct 29, 2026
f0026fb
fix(paseo): accept registered guest path spellings
wz-gsa Oct 30, 2026
faf1673
fix(paseo): match aggregate project paths in guest
wz-gsa Oct 30, 2026
9642a7e
fix(paseo): resolve host fixture aliases before guest checks
wz-gsa Oct 30, 2026
d3757a5
fix(paseo): fail closed on incomplete verifier probes
wz-gsa Oct 30, 2026
e4d6652
fix(paseo): require guest baseline for host reachability
wz-gsa Oct 30, 2026
35efa98
fix(paseo): require complete guest probe on published port
wz-gsa Oct 30, 2026
475aa0a
fix(paseo): avoid empty cache probe separator
wz-gsa Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions integrations/isolation/acq-kits/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,15 @@ it; `warn` is advisory.

**Unconverted kits** — `agentic-coding-playbook`, `oci-engine`, `opencode`,
`openchamber`, `opendesign`, `paseo`, `pi-coding-agent`,
`zscaler-ca-certificate`. These still use the older two-state verdict, so nothing
above applies to them. A non-zero exit means a check failed *or* a precondition
`zscaler-ca-certificate`. With the exception of `paseo`, these still use the
older two-state verdict, so nothing above applies to them. A non-zero exit
means a check failed *or* a precondition
could not be met — the two are not distinguished — and a `0` does **not**
establish that every check ran: these scripts report an unmet precondition with a
bare message that increments no counter, then exit `0` on the strength of the
checks that did run. Treat a `0` here as "nothing that ran failed", never as full
coverage.
coverage. `paseo` has its own three-state exit discipline (`0` measured pass,
`1` failure, `3` skipped/unverified), but does not source the shared reporter.

The authoritative classification is `scripts/tests/test_verify_contract.py`. It
executes the verdict contract for converted kits, asserts each unconverted kit
Expand Down
54 changes: 30 additions & 24 deletions integrations/isolation/acq-kits/paseo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,16 @@ as shown below.
export ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=<sha>&dir=integrations/isolation/acq-kits/paseo"
```

2. **Start the sandbox with `acq run`** (not `acq create` — see
2. **Start the sandbox with `acq run`** for a held PID-1 session, or use
detached `acq create` if your backend provides a keepalive (see
[Keep it running](#keep-it-running)):

```bash
acq run opencode /path/to/your/project
```

Leave this terminal open. It pins Paseo's worktree root under your project,
prints connect instructions, and holds the session open. Closing this terminal
stops the sandbox.
On a PID-1 entrypoint, leave this terminal open: the shim pins Paseo's
worktree root, prints connect instructions, and holds that session open.

3. **Open the browser UI.** Find the mapped host port and open it:

Expand Down Expand Up @@ -157,15 +157,11 @@ node --test integrations/isolation/acq-kits/paseo/tests/paseo-timing.test.mjs

## Keep it running

**Use `acq run`, and keep its terminal open.** A session-less sandbox is
auto-stopped shortly after its last session disconnects. `acq run` holds a session
for as long as its terminal is attached, which is what keeps the sandbox — and the
Paseo web UI — alive.

A detached `acq create` **does not** keep the sandbox running: with nothing
attached it is auto-stopped soon after. You may still use `acq create` to
pre-create a sandbox, but you must then `acq run --name <sandbox>` to actually use
it.
On a backend that runs the wrapper as **PID 1**, `acq run` holds its terminal
open so the sandbox and Paseo web UI stay up. A detached `acq create` can instead
use its own keepalive process and run the startup-supervised daemon without an
attached terminal; check `acq ls` and `acq ports <sandbox>` before assuming it
stopped. The wrapper's PID-1 hold is not a prerequisite for a detached sandbox.

If you don't want to tie up your working terminal, run `acq run` in a separate
terminal tab or a `tmux`/`screen` window and leave it attached.
Expand All @@ -174,9 +170,10 @@ terminal tab or a `tmux`/`screen` window and leave it attached.

The kit exposes **container** port `6767` (the Paseo daemon API + WebSocket +
bundled web UI), declared via the neutral `publishedPorts` field in `spec.yaml`.
`acq` publishes it to an **ephemeral `127.0.0.1` host port per sandbox** at create
time, so several sandboxes running this kit at once don't collide. Look up the
mapping:
`acq` publishes it to a host-loopback port at create time. Allocation is
backend-dependent: one MSB build maps guest 6767 to host 6767, so a second
listener can collide; other backends may allocate an ephemeral port. Look up
the actual mapping rather than assuming multiple sandboxes never collide:

```bash
acq ports <sandbox>
Expand All @@ -188,7 +185,9 @@ self-advertised local daemon on `localhost:<port>`, so a `127.0.0.1` address bar
can leave it showing "no hosts" with a `ws://…/ws` reconnect loop (see
TROUBLESHOOTING.md).

Want a **fixed** host port instead of the ephemeral one? Publish it explicitly:
Need a **different** host port? Publish it explicitly if the backend supports
remapping; this is a separate transport from the create-time mapping that the
verifier checks:

```bash
acq ports <sandbox> --publish 6767:6767 # this sandbox → host 6767
Expand Down Expand Up @@ -235,10 +234,10 @@ worktrees inside each project directory is not natively possible.
Startup can seed an absent config with a root beneath the **primary guest
workspace**, using `ACQ_WORKSPACE` before installation. Initialization preserves
every existing config entry. The `opencode` wrapper's shim then reconciles
`worktrees.root` with `<physical-attach-directory>/.paseo-worktrees` and restarts
the daemon worker only if the value changes. If startup seeds first, ordinary
attach avoids a premature restart; attach-directory overrides can still require
one. See [the lifecycle decision](docs/decisions/supervisor-owns-daemon-wrapper-pins-worktrees.md).
`worktrees.root` with `<physical-attach-directory>/.paseo-worktrees` only when
the wrapper is the PID-1 entrypoint, and restarts the daemon worker only if the
value changes. A bare `opencode` typed in a shell does not repin it. See
[the lifecycle decision](docs/decisions/supervisor-owns-daemon-wrapper-pins-worktrees.md).

To override the location, pre-set `worktrees.root` to an absolute path in
`config.json`; the shim only rewrites it when the value actually differs from
Expand All @@ -261,7 +260,8 @@ as one of Paseo's provider CLIs.
> real binary for **all** in-sandbox invocations. Calls with arguments pass
> straight through to the real opencode unchanged, so normal flows (including
> Paseo spawning `opencode` as a provider CLI) are unaffected; only a **no-arg**
> `opencode` is redirected to the kit shim (pin worktree root, hold PID 1). This
> `opencode` is redirected to the kit shim only at PID 1 (pin worktree root,
> hold the sandbox open). Outside PID 1 it opens the real OpenCode TUI. This
> applies only inside a sandbox with the kit enabled — it does not touch your host
> opencode.

Expand Down Expand Up @@ -320,9 +320,15 @@ python ../validate-kits.py

# Live end-to-end via acq (translates + applies the kit, publishes the port, and
# asserts the wrapper, the daemon + web UI, the port, the supervisor, the PID-1
# hold, daemon self-heal, and the worktree-root pin).
# hold's source structure (live PID-1 hold requires a separate interactive
# sandbox run), daemon self-heal, and the startup worktree-root value).
RUN_ACQ=1 ./scripts/verify

# The default detached verifier exits 3 when checks are skipped or unverified;
# exit 0 means all applicable measured checks passed. A live run still cannot
# demonstrate the PID-1 hold or real TUI routing without a separate interactive
# sandbox observation. Exit 1 means a measured check failed.

# Keep the sandbox afterward to inspect it:
KEEP=1 RUN_ACQ=1 ./scripts/verify

Expand All @@ -342,7 +348,7 @@ Validate and drive the kit through `acq`, which translates the neutral
paseo/
├── spec.yaml # the kit (caps, files, startup command, publishedPorts, env)
├── files/home/
│ ├── .local/bin/opencode # thin agent-named wrapper: passthrough to real opencode, else exec the shim
│ ├── .local/bin/opencode # thin wrapper: real TUI outside PID 1, kit shim at PID 1
│ ├── paseo-agent-shim # generic kit logic: pins worktree root, prints connect info, holds PID 1 on acq run
│ ├── paseo-start.sh # installs + supervises the Paseo daemon + web UI; runs the mount registrar
│ ├── paseo-set-worktrees-root.mjs # idempotent worktrees.root merge into config.json
Expand Down
24 changes: 12 additions & 12 deletions integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,13 +413,11 @@ acq exec <sandbox> -- sh -c '

## "The sandbox stopped" shortly after `acq create`

A session-less sandbox is **auto-stopped shortly after** the last session
disconnects. A detached `acq create` with nothing attached is stopped, taking the
daemon/UI down.

**Fix:** use `acq run` and keep its terminal open (the wrapper holds PID 1 for as
long as the terminal is attached). Use a separate tab or `tmux`/`screen` if you
don't want to tie up your working terminal. See the README "Keep it running".
Check `acq ls` and `acq ports <sandbox>` first. A detached create may have a
backend keepalive and a startup-supervised daemon; the PID-1 wrapper is not
required on that path. If the selected backend stopped the sandbox, run it in a
separate attached terminal and keep that terminal open while using the UI. See
the README "Keep it running" and inspect the startup log for any daemon failure.

## The Paseo CLI didn't install (UI unavailable, sandbox healthy)

Expand Down Expand Up @@ -466,9 +464,10 @@ Check what's configured and whether the daemon has it:
acq exec <sandbox> -- sh -c 'cat "${PASEO_HOME:-$HOME/.paseo}/config.json"'
```

- `worktrees.root` still absent or `$PASEO_HOME/worktrees` → you haven't run the
wrapper yet on `acq run` (a detached `acq create` alone won't pin it). Run
`acq run opencode <project>` once.
- `worktrees.root` still absent or `$PASEO_HOME/worktrees` → startup normally
seeds it from an absolute `ACQ_WORKSPACE`, when one is available. Check the
startup log and guest workspace first. The PID-1 `acq run` path may pin it;
a bare `opencode` typed in a shell will not.
- `worktrees.root` points at `<project>/.paseo-worktrees` but new worktrees still
land elsewhere → the daemon may not have restarted to pick it up. The wrapper
restarts the daemon only when the value **changes**; force a restart with the
Expand Down Expand Up @@ -553,8 +552,9 @@ acq exec <sandbox> -- sh -c 'rm -f "${PASEO_HOME:-$HOME/.paseo}/paseo.pid"'

The kit's `opencode` at `~/.local/bin/opencode` is a **thin wrapper** that shadows
the real binary. With arguments it `exec`s the real opencode unchanged; with no
arguments it `exec`s the generic kit shim (`~/paseo-agent-shim`), which pins
worktrees + holds PID 1. Confirm the shadow and that the real binary resolves:
arguments it `exec`s the shim only when the wrapper is PID 1; from a shell or
`acq exec` it opens the real OpenCode TUI without changing Paseo's worktree
root. Confirm the shadow and that the real binary resolves:

```bash
acq exec <sandbox> -- sh -c 'command -v opencode' # should be ~/.local/bin/opencode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,21 @@ agent-neutral shim.**

- **Thin wrapper** (`~/.local/bin/opencode`) — the ONLY agent-specific file:
- **With arguments:** `exec`s the real opencode unchanged.
- **With no arguments:** `exec`s the generic shim (`~/paseo-agent-shim`).
- **With no arguments as PID 1:** `exec`s the generic shim
(`~/paseo-agent-shim`) to hold the sandbox entrypoint open.
- **With no arguments outside PID 1:** `exec`s the real OpenCode TUI. A
shell or `acq exec` invocation must not repin the global worktree root or
restart the daemon.
- **Generic shim** (`~/paseo-agent-shim`) — knows nothing about any agent. It
pins Paseo's worktree root to `<PWD>/.paseo-worktrees` (see
`worktrees-root-global-only.md`), prints host-connect instructions, then
discriminates on `$$`:
- **PID 1** (the `acq run` entrypoint): `hold_pid1` — block forever so the
session's sandbox stays up.
- **not PID 1** (a shell / `acq exec`, with a detached `acq create` holding the
sandbox): exit cleanly and return the terminal.
- **not PID 1** (if invoked directly): it still runs the root-pinning and
daemon checks before reporting status and returning. The thin wrapper no
longer sends shell or `acq exec` invocations to this path; do not call the
shim directly from an unrelated project directory.
- Offers **no** TUI attach — there is no shared Paseo session to attach to
(`paseo-single-port-daemon.md`).

Expand All @@ -45,8 +51,9 @@ The PID-1 hold works because the shim inherits the wrapper's process id: when th
wrapper was the sandbox entrypoint (PID 1), `exec`ing the shim makes the shim
PID 1, so its `$$ -eq 1` branch fires and the hold keeps the sandbox alive.
Running the shim as a child would give it a non-1 pid and the hold would never
trigger. The verify script asserts the wrapper `exec`s the shim, and that the
shim carries the `$$ -eq 1` check + `hold_pid1`.
trigger. The verify script finds these statements in the shipped sources and
reports them as structural checks, not a live PID-1 observation. A separate
interactive `acq run` must demonstrate the actual entrypoint hold.

## Why passthrough cannot be dropped

Expand Down Expand Up @@ -82,13 +89,13 @@ mechanical step rather than speculative code we don't exercise.

## Consequences

- The kit keeps openchamber's proven PID-1 semantics (so `acq run` keeps the
sandbox alive), now living in the shim; the verify script's regression guards
assert both the exec-into-shim seam and the shim's hold.
- The kit retains the intended PID-1 hold semantics in the shim; the detached
verifier checks only source structure and marks live PID-1 behavior unverified
until a separate interactive `acq run` establishes it.
- No shared terminal↔browser session, by design — surfaced in `agentContext`,
README, and TROUBLESHOOTING.
- All reusable logic is in one agent-neutral file (`paseo-agent-shim`); the
agent-specific surface is a ~25-line wrapper.
agent-specific surface remains a thin wrapper.

## Links

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ does not depend on the host `curl`'s codec support.
(2,506,699) equals the `Accept-Encoding: br` wire bytes → ready.
- Node `brotliDecompressSync` decodes the served bundle to 15,779,639 bytes;
a truncated `.br` yields `ERR:unexpected end of file` (negative test).
- `sh -n` / `bash -n` clean on the shim and `verify`; offline gate passes.
- `sh -n` / `bash -n` clean on the shim and `verify`; measured offline checks
passed in this verification. The detached verifier now returns exit 3 if
checks are skipped; this historical evidence is not a live PID-1 assertion.

## Links

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,12 @@ non-absolute `--root` to avoid silently mis-placing them.

## Consequences

- Worktrees land under `<primary-project>/.paseo-worktrees/<projectHash>/<slug>`
once the entrypoint has run once on `acq run`. Before that first run, they
default to `$PASEO_HOME/worktrees`.
- Startup seeds an absent config with the primary guest `ACQ_WORKSPACE` when it
is a usable absolute directory. That allows a detached create to place
worktrees under `<primary-project>/.paseo-worktrees/<projectHash>/<slug>`
before a terminal attach. If startup cannot seed and no existing root is set,
Paseo defaults to `$PASEO_HOME/worktrees`. The PID-1 entrypoint reconciles a
different root on an interactive `acq run`; a shell's bare `opencode` does not.
- Applying a new root costs one daemon bounce (a few seconds of downtime while
the supervisor respawns the daemon). Idempotent: unchanged root ⇒ no bounce.
- A user who sets a custom absolute `worktrees.root` by hand will have it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,10 @@
# AND for Paseo itself, which launches `opencode` as a child
# process when opencode is the selected provider. Dropping
# passthrough would break Paseo's opencode provider.
# opencode → (no args) exec the generic, agent-neutral shim
# (paseo-agent-shim), which does all the Paseo/kit work
# (pin worktree root, print connect info, hold PID 1 on the
# `acq run` path). We `exec` it — NOT run it as a child — so
# the shim INHERITS this process's id; when this wrapper was
# the sandbox entrypoint (PID 1), the shim becomes PID 1 and
# its hold actually keeps the sandbox alive.
# opencode → when PID 1, exec the generic shim to hold the sandbox
# entrypoint open. Otherwise exec the real opencode TUI;
# a command typed in a shell must not repin Paseo's root
# or replace the TUI with connect instructions.
#
# REUSE FOR ANOTHER AGENT: copy this file to ~/.local/bin/<other-agent> and change
# only the agent name in the "resolve the real binary" block below. All shared
Expand All @@ -33,27 +30,46 @@ set -eu
HOME_DIR="${HOME:-/home/agent}"
SHIM="$HOME_DIR/paseo-agent-shim"

# The PID-1 entrypoint needs only the shim. Do not depend on PATH traversal or
# readlink -f here: neither the real OpenCode binary nor its resolver is needed
# to keep the sandbox alive.
if [ "$#" -eq 0 ] && [ "$$" -eq 1 ]; then
if [ ! -f "$SHIM" ]; then
echo "opencode wrapper: kit shim not found at $SHIM" >&2
exit 127
fi
exec sh "$SHIM"
fi

# --- Resolve the REAL opencode (never ourselves). ---------------------------
# We are ~/.local/bin/opencode and are first on PATH, so `command -v opencode`
# would find US. Search PATH entries explicitly and skip our own path.
self_dir="$HOME_DIR/.local/bin"
canon() { ( cd "$(dirname "$1")" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$(basename "$1")" ); }
self_canon="$(canon "$self_dir/opencode")"
# Resolve the executable itself as well as its parent: another PATH entry may
# symlink to this wrapper. A directory-only comparison would exec ourselves
# recursively on the non-PID-1 bare path.
canon() { readlink -f "$1" 2>/dev/null || return 1; }
self_canon="$(canon "$self_dir/opencode")" || exit 127
self_inode="$(stat -Lc '%d:%i' "$self_canon" 2>/dev/null || stat -f '%d:%i' "$self_canon" 2>/dev/null)" || exit 127
[ -n "$self_inode" ] || exit 127
real_opencode=""
_ifs="$IFS"; IFS=:
for _dir in $PATH; do
[ -n "$_dir" ] || continue
_cand="$_dir/opencode"
{ [ -x "$_cand" ] && [ ! -d "$_cand" ]; } || continue
[ "$(canon "$_cand")" = "$self_canon" ] && continue
_resolved="$(canon "$_cand")" || continue
[ "$_resolved" = "$self_canon" ] && continue
_inode="$(stat -Lc '%d:%i' "$_resolved" 2>/dev/null || stat -f '%d:%i' "$_resolved" 2>/dev/null)" || continue
[ "$_inode" = "$self_inode" ] && continue
real_opencode="$_cand"
break
done
IFS="$_ifs"

# --- Passthrough: any arguments go straight to the real opencode. ------------
# (If the real opencode is missing, only fail on the passthrough path — the
# no-arg shim path below does not need opencode at all.)
# (The PID-1 no-arg shim path does not require the real binary. A shell's
# no-arg invocation and all calls with arguments do require it.)
if [ "$#" -gt 0 ]; then
if [ -z "$real_opencode" ]; then
echo "opencode wrapper: could not locate the real opencode binary on PATH" >&2
Expand All @@ -62,9 +78,11 @@ if [ "$#" -gt 0 ]; then
exec "$real_opencode" "$@"
fi

# --- No args: hand off to the generic shim (exec, to preserve PID for the hold).
if [ ! -f "$SHIM" ]; then
echo "opencode wrapper: kit shim not found at $SHIM" >&2
exit 127
# --- No args in a shell/exec session: open the real TUI. ----------------------
if [ "$$" -ne 1 ]; then
if [ -z "$real_opencode" ]; then
echo "opencode wrapper: could not locate the real opencode binary on PATH" >&2
exit 127
fi
exec "$real_opencode"
fi
exec sh "$SHIM"
Loading
Loading