diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index cb922b19..5b7fd2ed 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -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 diff --git a/integrations/isolation/acq-kits/paseo/README.md b/integrations/isolation/acq-kits/paseo/README.md index d24bfb77..c93f75f7 100644 --- a/integrations/isolation/acq-kits/paseo/README.md +++ b/integrations/isolation/acq-kits/paseo/README.md @@ -29,16 +29,16 @@ as shown below. export ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=&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: @@ -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 ` 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 ` 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. @@ -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 @@ -188,7 +185,9 @@ self-advertised local daemon on `localhost:`, 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 --publish 6767:6767 # this sandbox → host 6767 @@ -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 `/.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 `/.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 @@ -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. @@ -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 @@ -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 diff --git a/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md index acc8ee51..8491b08e 100644 --- a/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md @@ -413,13 +413,11 @@ acq exec -- 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 ` 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) @@ -466,9 +464,10 @@ Check what's configured and whether the daemon has it: acq exec -- 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 ` 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 `/.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 @@ -553,8 +552,9 @@ acq exec -- 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 -- sh -c 'command -v opencode' # should be ~/.local/bin/opencode diff --git a/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-mixin-generic-wrapper.md b/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-mixin-generic-wrapper.md index eaf032d4..c53b5bd6 100644 --- a/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-mixin-generic-wrapper.md +++ b/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-mixin-generic-wrapper.md @@ -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 `/.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`). @@ -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 @@ -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 diff --git a/integrations/isolation/acq-kits/paseo/docs/decisions/ui-readiness-gate-immutable-cache.md b/integrations/isolation/acq-kits/paseo/docs/decisions/ui-readiness-gate-immutable-cache.md index 0d5a89cf..e7794c0a 100644 --- a/integrations/isolation/acq-kits/paseo/docs/decisions/ui-readiness-gate-immutable-cache.md +++ b/integrations/isolation/acq-kits/paseo/docs/decisions/ui-readiness-gate-immutable-cache.md @@ -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 diff --git a/integrations/isolation/acq-kits/paseo/docs/decisions/worktrees-root-global-only.md b/integrations/isolation/acq-kits/paseo/docs/decisions/worktrees-root-global-only.md index ae97fead..376a308c 100644 --- a/integrations/isolation/acq-kits/paseo/docs/decisions/worktrees-root-global-only.md +++ b/integrations/isolation/acq-kits/paseo/docs/decisions/worktrees-root-global-only.md @@ -66,9 +66,12 @@ non-absolute `--root` to avoid silently mis-placing them. ## Consequences -- Worktrees land under `/.paseo-worktrees//` - 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 `/.paseo-worktrees//` + 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 diff --git a/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode b/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode index 303ad775..14068f34 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode +++ b/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode @@ -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/ and change # only the agent name in the "resolve the real binary" block below. All shared @@ -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 @@ -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" diff --git a/integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim b/integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim index 2fd2d56c..3a6abd0e 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim +++ b/integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim @@ -12,7 +12,7 @@ # (see docs/decisions/paseo-mixin-generic-wrapper.md). # # INVOCATION CONTRACT: the thin wrapper must `exec` this shim (not run it as a -# child) on the no-arg path. That way the shim INHERITS the wrapper's process id — +# child) on the PID-1 no-arg entrypoint path. The shim inherits that process id — # so when the wrapper was the sandbox entrypoint, the shim is PID 1, and the # PID-1 hold below actually keeps the sandbox alive. Running it as a child would # give the shim a non-1 pid and the hold would never trigger. @@ -43,8 +43,8 @@ # PID 1 exits and the sandbox transitions running -> stopped (taking the Paseo # daemon/UI with it). So when `$$` is 1 the shim ends by BLOCKING FOREVER in # hold_pid1. On a detached `acq create` PID 1 is instead a tini keepalive shim -# and this shim is NOT PID 1 (it is reached from a shell / `acq exec`), so it -# exits cleanly. +# and this shim is non-PID-1 only if invoked directly. The opencode wrapper +# sends bare shell / `acq exec` calls to the real TUI instead. # # The Paseo daemon is UNSECURED (no PASEO_PASSWORD): the sandbox — an ephemeral # container with proxied, allow-listed egress and no host FS — is the security @@ -300,10 +300,9 @@ EOF # Are WE PID 1? This discriminates the two no-arg situations: # - PID 1 (we ARE the sandbox entrypoint, i.e. the `acq run` path): we MUST NOT # return, or PID 1 exits and the sandbox stops. Hold forever (hold_pid1). -# - NOT PID 1 (a detached `acq create` is already keeping the sandbox alive via -# its tini keepalive shim, and we were reached from a shell / `acq exec`): the -# daemon + UI are already supervised by the startup script, so there is -# nothing for us to hold — EXIT CLEANLY and give the terminal back. +# - NOT PID 1 (direct shim invocation only; the opencode wrapper instead opens +# the real TUI): the daemon + UI are already supervised by the startup +# script, so there is nothing for us to hold — EXIT CLEANLY. is_pid1() { [ "$$" -eq 1 ]; } paseo_timing shim_end diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 15577021..50c98179 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -15,7 +15,8 @@ # byte-complete over the host port (identity encoding; integrity guard), the # in-guest 0.0.0.0 bind (reachable via create-time publish) over the host port, the # install ran with lifecycle scripts disabled (hardening #2), the npm cache -# remains agent-owned, the respawn supervisor, the PID-1 hold, the +# remains agent-owned, the respawn supervisor, the PID-1 hold's source +# structure (live hold requires a separate interactive observation), the # worktree-root pin, daemon self-heal, and that projects are pre-populated # from the mounted host dirs — including that a read-write extra workspace IS # registered and a ":ro" one is SKIPPED. @@ -73,6 +74,18 @@ RUN_SBX="${RUN_SBX:-0}" SBX_KIT_DIR="${SBX_KIT_DIR:-}" # Container port the daemon binds (API + WS + web UI). Keep in sync with spec. PASEO_PORT="${PASEO_PORT:-6767}" +# This value is embedded in guest shell source below. Reject every non-decimal +# byte before constructing a command; bound length before numeric conversion. +if [[ ! "$PASEO_PORT" =~ ^[0-9]{1,5}$ ]] || + [ "$((10#$PASEO_PORT))" -lt 1 ] || [ "$((10#$PASEO_PORT))" -gt 65535 ]; then + printf 'Invalid PASEO_PORT (expected decimal 1..65535)\n' >&2 + exit 1 +fi +PASEO_PORT="$((10#$PASEO_PORT))" +if [ "$PASEO_PORT" -ne 6767 ]; then + printf 'Invalid PASEO_PORT (kit spec publishes container port 6767)\n' >&2 + exit 1 +fi REMOVED_DAEMON_LAUNCH_PATTERN='paseo[[:space:]]+daemon[[:space:]]+start|paseo[[:space:]]+daemon[[:space:]]+(start|run)[^#]*--(listen|web-ui|foreground)' # How long to wait for the background startup, which on FIRST boot also installs # the Paseo CLI (npm). Override with READY_TIMEOUT. @@ -87,9 +100,10 @@ printf 'gitdir: /tmp/paseo-verify-repo-b.git\n' > "$RW_CHILD_REPO_B/.git" pass=0 fail=0 +skipped=0 ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); } bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); } -skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; } +skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; skipped=$((skipped+1)); } warn() { printf ' \033[33mWARN\033[0m %s\n' "$1"; } info() { printf '\n\033[1m%s\033[0m\n' "$1"; } @@ -225,17 +239,27 @@ fi if [ -z "$DRIVER" ]; then info "Live sandbox checks (2-9): SKIPPED" + skipped=$((skipped+1)) echo " Set RUN_ACQ=1 to run the live end-to-end check via acq (preferred)." >&2 echo " (RUN_SBX=1 also works but needs a pre-translated sbx-v2 kit; see header.)" >&2 info "Summary" printf ' %d passed, %d failed\n' "$pass" "$fail" - [ "$fail" -eq 0 ] && printf ' \033[32mOffline gate passed.\033[0m\n' || printf ' \033[31mOffline gate failed — see above.\033[0m\n' - exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" + if [ "$fail" -gt 0 ]; then + printf ' \033[31mOffline gate failed — see above.\033[0m\n' + elif [ "$skipped" -gt 0 ]; then + printf ' \033[33mMeasured offline checks passed; %s skipped/unverified.\033[0m\n' "$skipped" + else + printf ' \033[32mOffline gate passed.\033[0m\n' + fi + [ "$fail" -eq 0 ] || exit 1 + [ "$skipped" -eq 0 ] || exit 3 + exit 0 fi if ! command -v "$DRIVER" >/dev/null 2>&1; then - echo " RUN_${DRIVER^^}=1 set but '$DRIVER' not on PATH. Install it and retry." >&2 - exit 1 + case "$DRIVER" in acq) driver_label=ACQ ;; sbx) driver_label=SBX ;; esac + echo " RUN_${driver_label}=1 set but '$DRIVER' not on PATH. Install it and retry." >&2 + exit 3 fi echo " driver: $DRIVER" @@ -318,14 +342,15 @@ if [ "$DRIVER" = "acq" ]; then else if [ -z "$SBX_KIT_DIR" ]; then info "Live sandbox checks (2-9): SKIPPED" + skipped=$((skipped+1)) echo " RUN_SBX=1 needs a PRE-TRANSLATED sbx-v2 kit dir in SBX_KIT_DIR." >&2 echo " sbx cannot parse the neutral hybrid/v1 spec directly. Prefer RUN_ACQ=1," >&2 echo " which translates automatically." >&2 info "Summary" printf ' %d passed, %d failed\n' "$pass" "$fail" - [ "$fail" -eq 0 ] && printf ' \033[32mOffline gate passed; live checks skipped.\033[0m\n' \ - || printf ' \033[31mOffline gate failed — see above.\033[0m\n' - exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" + [ "$fail" -eq 0 ] || { printf ' \033[31mOffline gate failed — see above.\033[0m\n'; exit 1; } + printf ' \033[33mMeasured offline checks passed; live checks skipped/unverified.\033[0m\n' + exit 3 fi create_kits=(--kit "$SBX_KIT_DIR") if [ -n "$ZSCALER_KIT_DIR" ] && [ -f "$ZSCALER_KIT_DIR/spec.yaml" ]; then @@ -463,26 +488,28 @@ else bundle="$(in_sbx "curl -fsS http://127.0.0.1:$PASEO_PORT/ 2>/dev/null" \ | grep -oE '/_expo/static/js/web/index-[0-9a-f]+\.js' | head -1)" if [ -z "$bundle" ]; then - warn "could not find the /_expo/static/js/web/index-.js bundle in index.html; skipping" + skip "could not find the /_expo/static/js/web/index-.js bundle in index.html; large-asset integrity unverified" else # In-guest identity baseline (isolates the daemon from the transport). - g_stat="$(in_sbx "curl -fsS -H 'Accept-Encoding: identity' -o /dev/null -w '%{http_code} %{size_download}' 'http://127.0.0.1:$PASEO_PORT$bundle' 2>/dev/null")" + g_stat="$(in_sbx "curl -fsS -H 'Accept-Encoding: identity' -o /dev/null -w '%{http_code} %{size_download}' 'http://127.0.0.1:$PASEO_PORT$bundle' 2>/dev/null && printf ' GUEST_FETCH_DONE'")" + case "$g_stat" in *' GUEST_FETCH_DONE') g_stat="${g_stat% GUEST_FETCH_DONE}" ;; *) g_stat="" ;; esac g_code="${g_stat%% *}"; g_size="${g_stat##* }" if [ "$g_code" = "200" ] && [ "${g_size:-0}" -gt 1000000 ] 2>/dev/null; then ok "in-guest: daemon serves the full identity bundle ($g_size bytes)" else - warn "in-guest identity fetch was '$g_stat' (expected '200 '); using >1MB heuristic for the host check" + skip "in-guest identity fetch was '$g_stat' (expected '200 '); exact size comparison unverified" g_size="" fi # HOST end-to-end, identity, NO --compressed. Compare bytes to the baseline. h_out="$(curl -fsS -H 'Accept-Encoding: identity' -o /dev/null \ -w '%{http_code} %{size_download}' "http://$HOST_HP$bundle" 2>/dev/null)"; h_rc=$? h_code="${h_out%% *}"; h_size="${h_out##* }" - if [ "$h_rc" -eq 0 ] && [ "$h_code" = "200" ] && \ - { { [ -n "$g_size" ] && [ "$h_size" = "$g_size" ]; } || \ - { [ -z "$g_size" ] && [ "${h_size:-0}" -gt 1000000 ] 2>/dev/null; }; }; then + if [ "$h_rc" -eq 0 ] && [ "$h_code" = "200" ] && + [ -n "$g_size" ] && [ "$h_size" = "$g_size" ]; then ok "HOST end-to-end: full identity bundle delivered over $HOST_HP (${h_size} bytes)" host_fetch_ok=yes # 6b gates its host-reachability assert on this + elif [ -z "$g_size" ]; then + skip "HOST identity fetch cannot be tied to an in-guest baseline; host reachability unverified (host='$h_out' rc=$h_rc)" else # rc=18/56 or a short count = real truncation; rc!=0 with size 0 on identity # is a transport error (identity needs no decoder), so it IS meaningful here. @@ -608,12 +635,18 @@ info "6d. Hardening: npm cache remains agent-owned" # root-owned files under ~/.npm and later blocks ordinary agent npm commands. Run # this even when install fails, because stale root-owned cache files may be the # reason install failed. -bad_cache_owner="$(in_sbx 'if [ -d "$HOME/.npm" ]; then find "$HOME/.npm" -not -user "$(id -u)" -print -quit 2>/dev/null; fi')" -if [ -n "$bad_cache_owner" ]; then - bad "npm cache contains files not owned by the agent user: $bad_cache_owner" -else - ok "npm cache has no non-agent-owned files under ~/.npm" -fi +cache_probe="$(in_sbx 'if [ ! -d "$HOME/.npm" ]; then printf CACHE_ABSENT; exit; fi; owner=$(id -u) || exit; [ -r "$HOME/.npm" ] && [ -x "$HOME/.npm" ] || exit; errors=$(mktemp) || exit; result=$(find "$HOME/.npm" -not -user "$owner" -print -quit 2>"$errors"); rc=$?; if [ "$rc" -ne 0 ] || [ -s "$errors" ]; then rm -f "$errors"; exit; fi; rm -f "$errors"; [ -z "$result" ] || printf "%s\n" "$result"; printf CACHE_PROBE_DONE')" +case "$cache_probe" in + CACHE_ABSENT) skip "npm cache directory absent; ownership unverified" ;; + *CACHE_PROBE_DONE) + bad_cache_owner="${cache_probe%CACHE_PROBE_DONE}" + if [ -n "$bad_cache_owner" ]; then + bad "npm cache contains files not owned by the agent user: $bad_cache_owner" + else + ok "npm cache has no non-agent-owned files under ~/.npm" + fi ;; + *) skip "npm cache ownership probe did not complete; ownership unverified" ;; +esac info "7. Respawn supervisor running (auto-restart) — the daemon" # The daemon runs under a respawn loop marked "supervisor:paseo-daemon" in its @@ -626,56 +659,47 @@ case "${daemon_sup:-0}" in *) bad "found ${daemon_sup} paseo-daemon supervisors (expected 1 — duplicate respawner?)" ;; esac -info "8. Entrypoint holds PID 1 when it IS the entrypoint, exits cleanly otherwise — regression guard" +info "8. Entrypoint structure and non-PID-1 behavior (PID-1 runtime requires a separate live run)" # The kit's entrypoint behavior is split: a thin per-agent wrapper # (~/.local/bin/opencode) that either passes through to the real agent binary or # EXECs the generic shim (~/paseo-agent-shim), and the shim, which carries the # PID-1 hold. We check three invariants: -# (a) the thin wrapper EXECs the shim on the no-arg path (so the shim inherits -# the wrapper's pid — PID 1 on the `acq run` path; a child would break hold); +# (a) the thin wrapper EXECs the shim only as PID 1, preserving the pid for +# the hold; outside PID 1 it execs the real OpenCode binary; # (b) the shim discriminates on `$$ -eq 1` and calls hold_pid1 on that branch; -# (c) live: run the wrapper no-arg + no-TTY via exec (NOT PID 1) and confirm it -# RETURNS promptly rather than hanging. +# these source checks do NOT demonstrate the live PID-1 handoff; +# (c) the offline wrapper tests exercise no-arg non-PID-1 passthrough with a +# fake real binary. Do NOT run the real TUI from a read-write guest mount +# merely to verify its routing; real TUI behavior remains unverified here. wrapper_src="$(in_sbx 'cat ~/.local/bin/opencode 2>/dev/null')" shim_src="$(in_sbx 'cat ~/paseo-agent-shim 2>/dev/null')" if [ -z "$wrapper_src" ]; then bad "could not read ~/.local/bin/opencode from the sandbox" elif printf '%s\n' "$wrapper_src" | grep -Eq 'exec[[:space:]]+sh[[:space:]]+"?\$SHIM"?|exec[[:space:]]+sh[[:space:]]+.*paseo-agent-shim'; then - ok "thin wrapper execs the generic shim on the no-arg path (preserves PID for the hold)" + skip "wrapper source contains exec-to-shim; actual PID-1 branch not observed" else bad "thin wrapper does not exec the shim — no-arg path won't inherit PID 1, breaking the hold" fi +skip "PID-1 hold not observed by this detached sandbox probe; validate an interactive acq run separately" if [ -z "$shim_src" ]; then bad "could not read ~/paseo-agent-shim from the sandbox to check the PID-1 discriminator" else if printf '%s\n' "$shim_src" | grep -Eq '\$\$"?[[:space:]]*-eq[[:space:]]*1'; then - ok "shim discriminates on PID 1 (\$\$ -eq 1) before deciding to hold vs. exit" + skip "shim source checks PID 1; actual sandbox entrypoint hold not observed" else bad "shim has no \$\$ -eq 1 check — it cannot tell the acq run (PID 1) path from acq exec" fi if printf '%s\n' "$shim_src" | grep -Eq '^[[:space:]]*hold_pid1[[:space:]]*$'; then - ok "shim calls hold_pid1 on the PID-1 branch" + skip "shim source contains hold_pid1; runtime hold not observed" else bad "shim does not call hold_pid1 — the acq run entrypoint could return and stop the sandbox" fi fi -wrapper_probe="$(in_sbx ' - if command -v timeout >/dev/null 2>&1; then - timeout 20 opencode /dev/null 2>&1 - echo "rc=$?" - else - echo "rc=notimeout" - fi -')" -case "$wrapper_probe" in - *rc=124*) bad "entrypoint HUNG when NOT PID 1 (via exec); it should exit cleanly on that path" ;; - *rc=notimeout*) skip "could not run the wrapper-exit probe (no 'timeout' in sandbox)" ;; - *) ok "wrapper exits cleanly when NOT PID 1 (${wrapper_probe})" ;; -esac +skip "real TUI routing not run in this verifier; offline wrapper fixtures cover the non-PID-1 branch" info "9. Worktree root pinned under the project + daemon self-heals — regression guards" -# (a) worktree-root pin: running the wrapper no-arg from the PRIMARY WORKSPACE cwd -# should set worktrees.root to /.paseo-worktrees in config.json. +# (a) worktree-root pin: the startup-seeded config should retain the primary +# workspace root. A non-PID-1 wrapper must not repin it from its own cwd. # # WHERE IS THE PRIMARY WORKSPACE INSIDE THE GUEST? acq bind-mounts the first # positional path at the SAME (canonicalized) absolute path in the guest, and @@ -685,25 +709,30 @@ info "9. Worktree root pinned under the project + daemon self-heals — regressi # file /var/lib/acq/workspace; that is the cwd the real entrypoint gets. On # macOS the canonical path differs from $WORK only by the /var -> /private/var # symlink, so we read the guest's recorded value (authoritative) and fall back -# to $WORK. Run the wrapper from that cwd, then read back config.json. +# to $WORK. Read back config.json without invoking the non-PID-1 wrapper. GUEST_WS="${GUEST_WS:-}" if [ -z "$GUEST_WS" ]; then GUEST_WS="$(in_sbx 'cat /var/lib/acq/workspace 2>/dev/null')" [ -n "$GUEST_WS" ] || GUEST_WS="$WORK" fi -# Run the wrapper from the guest workspace cwd, then read back config.json. -pin_out="$(in_sbx "cd '$GUEST_WS' 2>/dev/null && (command -v timeout >/dev/null 2>&1 && timeout 30 opencode /dev/null 2>&1; true); cat \"\${PASEO_HOME:-\$HOME/.paseo}/config.json\" 2>/dev/null")" -if printf '%s' "$pin_out" | grep -q "\"root\": \"$GUEST_WS/.paseo-worktrees\""; then - ok "worktrees.root pinned under the project ($GUEST_WS/.paseo-worktrees)" -elif printf '%s' "$pin_out" | grep -Eq '"root": *"[^"]*/\.paseo-worktrees"'; then - # It IS pinned to some /.paseo-worktrees, just not the exact dir we probed. - # That still proves the mechanism; surface the actual value and pass. - _actual="$(printf '%s' "$pin_out" | grep -Eo '"root": *"[^"]*/\.paseo-worktrees"' | head -1)" - ok "worktrees.root pinned under a project dir ($_actual); differs from probed cwd '$GUEST_WS'" -else - bad "worktrees.root not pinned to any /.paseo-worktrees — config.json was:" - printf '%s\n' "$pin_out" | sed 's/^/ | /' -fi +# Startup owns the initial root; read only worktrees.root, not an unrelated +# object with a matching root key. +check_worktrees_root() { + local expected="$1" observed="$2" + if [ "$observed" = "valid:$expected" ]; then + ok "configured worktrees.root equals the primary guest workspace ($expected); physical containment and seed provenance unverified" + return + fi + case "$observed" in + valid:*) skip "worktrees.root differs from '$expected'; startup may have preserved existing config" ;; + missing) skip "no config exists; optional startup seed not measured" ;; + transport) skip "could not query guest config; root state unverified" ;; + *) bad "Paseo config is unreadable, invalid JSON, or has no valid worktrees.root" ;; + esac +} +pin_out="$(in_sbx "node -e 'const fs = require(\"fs\"); const p = (process.env.PASEO_HOME || process.env.HOME + \"/.paseo\") + \"/config.json\"; try { const c = JSON.parse(fs.readFileSync(p, \"utf8\")); const root = c?.worktrees?.root; process.stdout.write(typeof root === \"string\" && root.startsWith(\"/\") && !/[\\u0000-\\u001f\\u007f]/.test(root) ? \"valid:\" + root : \"invalid\"); } catch (e) { process.stdout.write(e.code === \"ENOENT\" ? \"missing\" : \"invalid\"); }' && printf PROBE_DONE")" +case "$pin_out" in *PROBE_DONE) pin_out="${pin_out%PROBE_DONE}" ;; *) pin_out=transport ;; esac +check_worktrees_root "$GUEST_WS/.paseo-worktrees" "$pin_out" # (b) daemon self-heal: if the daemon dies, supervisor:paseo-daemon must relaunch # it. Kill ONLY the daemon tree (argv matches "paseo daemon run" but NOT the @@ -784,10 +813,27 @@ else } rw_base="$(basename "$RW_FIXTURE")" - rw_child_a_base="$(basename "$RW_CHILD_REPO_A")" - rw_child_b_base="$(basename "$RW_CHILD_REPO_B")" ro_base="$(basename "$RO_FIXTURE")" + # The registrar supplies the /proc/mounts spelling to Paseo; the API may + # return that spelling or its physical guest path. Require a resolvable guest + # directory, then compare either exact spelling (never a suffix or regex). + quote_guest_path() { printf "'%s'" "${1//\'/\'\\\'\'}"; } + host_physical_path() { (cd -P -- "$1" 2>/dev/null && pwd -P); } + guest_physical_path() { + local target="$1" + # Host /var may be /private/var in the guest. For host-owned fixtures, + # resolve the host spelling before passing it across the mount boundary. + if [ "${2:-}" = host ]; then + target="$(host_physical_path "$target")" || return 1 + fi + in_sbx "cd $(quote_guest_path "$target") 2>/dev/null && pwd -P" + } + registered_fixture_path() { + [ -n "$2" ] && { printf '%s\n' "$project_roots" | grep -Fqx -- "$1" || + printf '%s\n' "$project_roots" | grep -Fqx -- "$2"; } + } + # Expected set: qualifying rw virtiofs mounts, expanded by the actual registrar # helper so this live check cannot drift from paseo-register-mounts.mjs. expected_projects="$(in_sbx ' @@ -822,9 +868,11 @@ else while :; do project_roots="$(list_project_roots)" missing="" - for m in $expected_projects; do - printf '%s\n' "$project_roots" | grep -qx "$m" || missing="$missing $m" - done + while IFS= read -r m; do + [ -n "$m" ] || continue + physical_m="$(guest_physical_path "$m")" + registered_fixture_path "$m" "$physical_m" || missing="${missing}${missing:+, }$m" + done <<< "$expected_projects" [ -z "$missing" ] && break [ "$(date +%s)" -ge "$reg_deadline" ] && break sleep "$POLL_INTERVAL" @@ -838,8 +886,13 @@ else fi # POSITIVE: child repos under the read-write parent fixture must be registered. - if printf '%s\n' "$project_roots" | grep -q "${rw_base}/${rw_child_a_base}\$" && \ - printf '%s\n' "$project_roots" | grep -q "${rw_base}/${rw_child_b_base}\$"; then + # Compare the child fixtures by the same rule as the aggregate set. + guest_rw_a="$(guest_physical_path "$RW_CHILD_REPO_A" host)" + guest_rw_b="$(guest_physical_path "$RW_CHILD_REPO_B" host)" + guest_rw_parent="$(guest_physical_path "$RW_FIXTURE" host)" + guest_ro="$(guest_physical_path "$RO_FIXTURE" host)" + if registered_fixture_path "$RW_CHILD_REPO_A" "$guest_rw_a" && + registered_fixture_path "$RW_CHILD_REPO_B" "$guest_rw_b"; then ok "child repos under read-write parent fixture ($rw_base) registered as projects" else bad "child repos under read-write parent fixture ($rw_base) were NOT registered" @@ -847,7 +900,9 @@ else # NEGATIVE: the parent fixture should not be registered when direct child repos # were found and registered instead. - if printf '%s\n' "$project_roots" | grep -q "${rw_base}\$"; then + if [ -z "$guest_rw_parent" ]; then + skip "read-write parent mount not resolvable in guest; expansion unverified" + elif registered_fixture_path "$RW_FIXTURE" "$guest_rw_parent"; then bad "read-write parent fixture ($rw_base) was registered instead of only its child repos" else ok "read-write parent fixture ($rw_base) correctly expanded to child repos" @@ -858,7 +913,9 @@ else # :ro — in which case we skip rather than falsely fail). ro_is_ro="$(in_sbx "awk '\$3==\"virtiofs\" && \$4 ~ /^ro/ && \$2 ~ /${ro_base}\$/ {print \"yes\"}' /proc/mounts | head -1")" if [ "$ro_is_ro" = "yes" ]; then - if printf '%s\n' "$project_roots" | grep -q "${ro_base}\$"; then + if [ -z "$guest_ro" ]; then + skip "read-only mount not resolvable in guest; exclusion unverified" + elif registered_fixture_path "$RO_FIXTURE" "$guest_ro"; then bad "read-only fixture ($ro_base) was wrongly registered (should be skipped)" else ok "read-only fixture ($ro_base) correctly skipped" @@ -877,6 +934,13 @@ else fi info "Summary" -[ "$fail" -eq 0 ] && printf ' \033[32mAll checks passed.\033[0m\n' || printf ' \033[31mSome checks failed — see notes above.\033[0m\n' +if [ "$fail" -gt 0 ]; then + printf ' \033[31mSome checks failed — see notes above.\033[0m\n' +elif [ "$skipped" -gt 0 ]; then + printf ' \033[33mMeasured checks passed; %s skipped/unverified (including live PID-1 hold and real TUI).\033[0m\n' "$skipped" +else + printf ' \033[32mAll measured checks passed.\033[0m\n' +fi -[ "$fail" -eq 0 ] +[ "$fail" -eq 0 ] || exit 1 +[ "$skipped" -eq 0 ] || exit 3 diff --git a/integrations/isolation/acq-kits/paseo/spec.yaml b/integrations/isolation/acq-kits/paseo/spec.yaml index c5310e54..150e4cb2 100644 --- a/integrations/isolation/acq-kits/paseo/spec.yaml +++ b/integrations/isolation/acq-kits/paseo/spec.yaml @@ -28,12 +28,11 @@ # the wrapper is what the entrypoint runs on the interactive `acq run` path. # With arguments it passes straight through to the real opencode (so opencode # still works normally — including as a Paseo provider CLI that Paseo spawns). -# With NO arguments it execs the GENERIC shim (/home/agent/paseo-agent-shim), -# which does all the kit work: (1) pin Paseo's worktree root to the current -# project (see WORKTREES below), (2) print host-connect instructions, and (3) — -# only when it is PID 1 (the `acq run` entrypoint) — hold the foreground so -# THAT session's sandbox stays open. The wrapper `exec`s the shim so the shim -# inherits its process id (PID 1 on the entrypoint path). Only the thin wrapper +# With NO arguments at PID 1 it execs the GENERIC shim +# (/home/agent/paseo-agent-shim), which pins the worktree root, prints host +# instructions and holds the sandbox open. Outside PID 1 a bare invocation +# opens the real OpenCode TUI without changing the Paseo daemon's root. The +# wrapper `exec`s the shim so it inherits PID 1. Only the thin wrapper # is agent-specific; the shim knows nothing about opencode, so the kit is # reusable with other agents by dropping another thin wrapper next to it. # @@ -134,12 +133,12 @@ files: # The `opencode` wrapper. Dropped at ~/.local/bin/opencode, which is FIRST on # the image PATH, so it shadows the real opencode and is what the sandbox # entrypoint (bare `opencode`) runs. It is a THIN, agent-named shell: passthrough - # to the real opencode with args, else exec the generic shim. See the header - # DESIGN note. + # to the real opencode with args or outside PID 1; as PID 1 exec the shim. + # See the header DESIGN note. - path: /home/agent/.local/bin/opencode mode: "0755" source: files/home/.local/bin/opencode - description: Thin agent-named wrapper — passthrough to the real opencode, else exec the generic kit shim + description: Thin agent-named wrapper — real opencode outside PID 1, generic kit shim at PID 1 - path: /home/agent/paseo-agent-shim mode: "0755" source: files/home/paseo-agent-shim @@ -256,16 +255,18 @@ agentContext: | `acq create --name opencode ` starts the daemon + UI; then find the host port with `acq ports ` and open the browser. The `opencode` **wrapper** (at `~/.local/bin/opencode`, first on PATH) is a thin shim over - generic kit logic: running it with **no arguments** pins Paseo's worktree root - under your project and prints host-connect instructions (and, on the `acq run` - path, holds the session open so the sandbox stays running). Running + generic kit logic: as PID 1, running it with **no arguments** pins Paseo's + worktree root and holds the sandbox open. Outside PID 1, a bare `opencode` + starts its real terminal TUI without changing Paseo's root. Running `opencode ` (e.g. `opencode run …`, `opencode auth login`) passes straight through to the real opencode — so opencode still works as one of Paseo's provider CLIs. **Reaching it from the host.** The kit publishes container port 6767; acq's - active backend maps it to an ephemeral host loopback port, per sandbox. Find it - and open the browser: + active backend maps it to a host-loopback port. Allocation is backend-dependent: + some MSB builds map guest 6767 to host 6767 and may collide with another + listener; other backends may choose an ephemeral host port. Find the actual + mapping and open the browser: ``` acq ports # host port for 6767 @@ -285,8 +286,9 @@ agentContext: | **Worktrees.** Paseo keeps agent git worktrees under a single global root. This kit can initialize an absent config from the primary guest `ACQ_WORKSPACE` before installation, preserving existing configuration during initialization. - The `opencode` wrapper reconciles the root with its physical working directory - plus `/.paseo-worktrees` and restarts only when that value changes. See the kit's + Only the PID-1 `opencode` wrapper path reconciles the root with its physical + working directory plus `/.paseo-worktrees` and restarts when it changes. See + the kit's `docs/decisions/supervisor-owns-daemon-wrapper-pins-worktrees.md`. **Projects.** Every read-write host directory mounted into the sandbox is diff --git a/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs b/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs new file mode 100644 index 00000000..860ec419 --- /dev/null +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { link, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; + +const wrapper = new URL("../files/home/.local/bin/opencode", import.meta.url); + +async function fixture(t) { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-wrapper-")); + t.after(() => rm(home, { recursive: true, force: true })); + const shadow = path.join(home, ".local/bin"); + const real = path.join(home, "real/bin"); + await mkdir(shadow, { recursive: true }); + await mkdir(real, { recursive: true }); + await writeFile(path.join(shadow, "opencode"), await readFile(wrapper), { mode: 0o755 }); + await writeFile(path.join(real, "opencode"), '#!/bin/sh\nprintf "real:%s\\n" "$@"\nexit 23\n', { mode: 0o755 }); + await writeFile(path.join(home, "paseo-agent-shim"), '#!/bin/sh\nprintf "shim\\n"\nexit 37\n', { mode: 0o755 }); + const env = { HOME: home, PATH: `${shadow}:${real}:/usr/bin:/bin` }; + function run(args, overrides = {}) { + const result = spawnSync(path.join(shadow, "opencode"), args, { + env: { ...env, ...overrides }, encoding: "utf8", timeout: 5000, + }); + assert.ifError(result.error); + return result; + } + return { home, run }; +} + +test("bare opencode outside PID 1 execs the real TUI without running the Paseo shim", async (t) => { + const f = await fixture(t); + const result = f.run([]); + assert.equal(result.status, 23); + assert.equal(result.stdout, "real:\n"); + assert.doesNotMatch(result.stdout + result.stderr, /shim|Paseo daemon/); + await assert.rejects(readFile(path.join(f.home, ".paseo/config.json")), { code: "ENOENT" }); +}); + +test("arguments are passed unchanged to the real opencode", async (t) => { + const f = await fixture(t); + const result = f.run(["run", "two words"]); + assert.equal(result.status, 23); + assert.equal(result.stdout, "real:run\nreal:two words\n"); +}); + +test("a missing real opencode fails visibly outside PID 1", async (t) => { + const f = await fixture(t); + const result = f.run([], { PATH: `${path.join(f.home, ".local/bin")}:/usr/bin:/bin` }); + assert.equal(result.status, 127); + assert.match(result.stderr, /could not locate the real opencode binary/); + assert.doesNotMatch(result.stdout, /shim/); +}); + +test("a later PATH alias to the wrapper is skipped rather than re-executed", async (t) => { + const f = await fixture(t); + const alias = path.join(f.home, "alias-bin"); + await mkdir(alias); + await symlink(path.join(f.home, ".local/bin/opencode"), path.join(alias, "opencode")); + const result = f.run([], { PATH: `${path.join(f.home, ".local/bin")}:${alias}:${path.join(f.home, "real/bin")}:/usr/bin:/bin` }); + assert.equal(result.status, 23); + assert.equal(result.stdout, "real:\n"); +}); + +test("an alias-only PATH fails visibly without recursion", async (t) => { + const f = await fixture(t); + const alias = path.join(f.home, "alias-bin"); + await mkdir(alias); + await symlink(path.join(f.home, ".local/bin/opencode"), path.join(alias, "opencode")); + const result = f.run([], { PATH: `${path.join(f.home, ".local/bin")}:${alias}:/usr/bin:/bin` }); + assert.equal(result.status, 127); + assert.match(result.stderr, /could not locate the real opencode binary/); +}); + +test("a later hard-link alias cannot cause recursive wrapper execution", async (t) => { + const f = await fixture(t); + const alias = path.join(f.home, "hardlink-bin"); + await mkdir(alias); + await link(path.join(f.home, ".local/bin/opencode"), path.join(alias, "opencode")); + const result = f.run([], { PATH: `${path.join(f.home, ".local/bin")}:${alias}:${path.join(f.home, "real/bin")}:/usr/bin:/bin` }); + assert.equal(result.status, 23); + assert.equal(result.stdout, "real:\n"); +}); + +test("PID-1 shim handoff precedes any readlink dependency", async () => { + const source = await readFile(wrapper, "utf8"); + const pidBranch = source.indexOf('if [ "$#" -eq 0 ] && [ "$$" -eq 1 ]; then'); + const selfResolution = source.indexOf('self_canon="$(canon'); + assert.ok(pidBranch > 0 && selfResolution > pidBranch); + assert.match(source.slice(pidBranch, selfResolution), /exec sh "\$SHIM"/); +}); diff --git a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs index a26746e7..2d754ae2 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -1,12 +1,18 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; const verify = await readFile(new URL("../scripts/verify", import.meta.url), "utf8"); const start = verify.indexOf("daemon_status_is_running() {"); const end = verify.indexOf("\nsbx_ports_out() {", start); const statusCheck = verify.slice(start, end); +const rootCheckStart = verify.indexOf("check_worktrees_root() {"); +const rootCheckEnd = verify.indexOf("\npin_out=", rootCheckStart); +assert.ok(rootCheckStart > 0 && rootCheckEnd > rootCheckStart); +const rootCheck = verify.slice(rootCheckStart, rootCheckEnd); function isRunning(status) { const result = spawnSync("bash", ["-c", `${statusCheck}\ndaemon_status_is_running`], { @@ -38,3 +44,179 @@ test("rejects non-running and malformed daemon status", () => { }); assert.equal(result.status, 1); }); + +test("worktree check reads the exact nested root, not a matching unrelated field", async (t) => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-verify-root-")); + t.after(() => rm(home, { recursive: true, force: true })); + await mkdir(path.join(home, ".paseo")); + const config = path.join(home, ".paseo/config.json"); + const pinLine = verify.split("\n").find((line) => line.startsWith('pin_out="$(in_sbx ')); + assert.ok(pinLine, "verifier must have an executable root query"); + function root() { + const result = spawnSync("bash", ["-c", `in_sbx() { bash -c "$1"; }\n${pinLine}\npin_out="${"${pin_out%PROBE_DONE}"}"\nprintf '%s' "$pin_out"`], { + env: { HOME: home, PATH: process.env.PATH }, encoding: "utf8", timeout: 5000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + return result.stdout; + } + await writeFile(config, JSON.stringify({ other: { root: "/primary/.paseo-worktrees" }, worktrees: { root: "/wrong/.paseo-worktrees" } })); + assert.equal(root(), "valid:/wrong/.paseo-worktrees"); + function verdict() { + const result = spawnSync("bash", ["-c", `${rootCheck}\nfail=0; skipped=0; ok() { printf 'PASS:%s\\n' "$1"; }; bad() { fail=$((fail+1)); printf 'FAIL:%s\\n' "$1"; }; skip() { skipped=$((skipped+1)); printf 'SKIP:%s\\n' "$1"; }; check_worktrees_root "/primary/.paseo-worktrees" "${root()}"; [ "$fail" -eq 0 ] || exit 1; [ "$skipped" -eq 0 ] || exit 3`], { encoding: "utf8", timeout: 5000 }); + assert.ifError(result.error); + return result; + } + assert.equal(verdict().status, 3); + assert.match(verdict().stdout, /SKIP:worktrees.root/); + await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/.paseo-worktrees" } })); + assert.equal(root(), "valid:/primary/.paseo-worktrees"); + assert.equal(verdict().status, 0); + assert.match(verdict().stdout, /PASS:configured worktrees.root/); + await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/my project/.paseo-worktrees" } })); + assert.equal(root(), "valid:/primary/my project/.paseo-worktrees"); + await writeFile(config, "{invalid"); + assert.equal(root(), "invalid"); + assert.equal(verdict().status, 1); + await writeFile(config, JSON.stringify({ worktrees: { root: "relative/path" } })); + assert.equal(root(), "invalid"); + assert.equal(verdict().status, 1); + await writeFile(config, JSON.stringify({ worktrees: { root: " " } })); + assert.equal(root(), "invalid"); + assert.equal(verdict().status, 1); + await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/.paseo-worktrees\u0000" } })); + assert.equal(root(), "invalid"); + assert.equal(verdict().status, 1); + await rm(config); + assert.equal(root(), "missing"); + assert.equal(verdict().status, 3); + assert.match(verdict().stdout, /SKIP:no config exists/); + const guestProbe = verify.split("\n").find((line) => line.startsWith('pin_out="$(in_sbx ')); + assert.ok(guestProbe); + const transport = spawnSync("bash", ["-c", `in_sbx() { return 1; }\n${guestProbe}\ncase "$pin_out" in *PROBE_DONE) : ;; *) pin_out=transport ;; esac\nprintf '%s' "$pin_out"`], { encoding: "utf8", timeout: 5000 }); + assert.ifError(transport.error); + assert.equal(transport.stdout, "transport"); +}); + +test("project comparison keeps spaced and canonicalized guest paths literal", async (t) => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-verify-project-")); + t.after(() => rm(home, { recursive: true, force: true })); + const physical = path.join(home, "physical"); + const alias = path.join(home, "alias"); + const project = path.join(alias, "a space", "it's-repo"); + await mkdir(path.join(physical, "a space", "it's-repo"), { recursive: true }); + await symlink(physical, alias); + const source = verify.split("\n"); + const first = source.findIndex((line) => line.includes("quote_guest_path()")); + const last = source.findIndex((line, at) => at > first && line.includes('guest_rw_a="$(guest_physical_path')); + assert.ok(first > 0 && last > first, "verifier must resolve guest paths before comparison"); + const helpers = source.slice(first, source.findIndex((line, at) => at > first && line.includes(' # Expected set:'))).join("\n"); + const script = `${helpers}\nin_sbx() { bash -c "$1"; }\nexpected=$(guest_physical_path "$1")\n[ "$expected" = "$2" ] || exit 1\nproject_roots="$3"\nregistered_fixture_path "$1" "$expected"`; + const canonical = path.join(physical, "a space", "it's-repo"); + for (const [registered, status] of [[project, 0], [canonical, 0], [path.join(home, "other"), 1]]) { + const result = spawnSync("bash", ["-c", script, "test", project, canonical, registered], { + encoding: "utf8", timeout: 5000, + }); + assert.ifError(result.error); + assert.equal(result.status, status, result.stderr); + } + const loopStart = verify.indexOf(' while IFS= read -r m; do', verify.indexOf(' # Expected set:')); + const loopEnd = verify.indexOf(' done <<< "$expected_projects"', loopStart); + assert.ok(loopStart > 0 && loopEnd > loopStart, "aggregate check must read project paths line by line"); + const loop = verify.slice(loopStart, loopEnd + ' done <<< "$expected_projects"'.length); + for (const [registered, missing] of [[project, false], [canonical, false], [path.join(home, "other"), true]]) { + const aggregateScript = `${helpers}\nin_sbx() { bash -c "$1"; }\nexpected_projects="$1"\nproject_roots="$2"\nmissing=""\n${loop}\n[ -z "$missing" ]`; + const result = spawnSync("bash", ["-c", aggregateScript, "test", project, registered], { + encoding: "utf8", timeout: 5000, + }); + assert.ifError(result.error); + assert.equal(result.status, missing ? 1 : 0, result.stderr); + } + // Simulate a guest that mounts only the physical host spelling: the alias + // itself is absent across the boundary, just like host /var vs guest + // /private/var on macOS. + const hostMapScript = `${helpers}\nin_sbx() { case "$1" in *"$GUEST_ALIAS"*) return 1;; esac; bash -c "$1"; }\nexpected=$(guest_physical_path "$1" host)\n[ "$expected" = "$2" ] || exit 1\nproject_roots="$2"\nregistered_fixture_path "$1" "$expected"`; + const hostMap = spawnSync("bash", ["-c", hostMapScript, "test", project, canonical], { + env: { ...process.env, GUEST_ALIAS: alias }, encoding: "utf8", timeout: 5000, + }); + assert.ifError(hostMap.error); + assert.equal(hostMap.status, 0, hostMap.stderr); +}); + +test("port override rejects guest shell source and out-of-range ports", () => { + const start = verify.indexOf('PASEO_PORT="${PASEO_PORT:-6767}"'); + const end = verify.indexOf('\nREMOVED_DAEMON_LAUNCH_PATTERN=', start); + assert.ok(start > 0 && end > start); + const guard = verify.slice(start, end); + for (const [port, status, normalized] of [["6767", 0, "6767"], ["06767", 0, "6767"], ["00080", 1], ["0", 1], ["65536", 1], ["6767;id", 1], ["1$(id)", 1], ["-1", 1]]) { + const result = spawnSync("bash", ["-c", `${guard}\nprintf 'port=%s\\n' "$PASEO_PORT"`], { + env: { ...process.env, PASEO_PORT: port }, encoding: "utf8", timeout: 5000, + }); + assert.ifError(result.error); + assert.equal(result.status, status, port); + if (normalized) assert.equal(result.stdout, `port=${normalized}\n`); + else assert.equal(result.stdout, ""); + } +}); + +test("cache ownership probe treats missing completion as unverified", () => { + const start = verify.indexOf('cache_probe="$(in_sbx '); + const end = verify.indexOf('\ninfo "7. Respawn supervisor', start); + assert.ok(start > 0 && end > start); + const check = verify.slice(start, end); + for (const [probe, verdict] of [["", "SKIP"], ["CACHE_ABSENT", "SKIP"], ["CACHE_PROBE_DONE", "PASS"], ["/foreign/file\nCACHE_PROBE_DONE", "FAIL"]]) { + const script = `in_sbx() { printf '%s' "$PROBE"; }\nok() { printf 'PASS'; }; bad() { printf 'FAIL'; }; skip() { printf 'SKIP'; };\n${check}`; + const result = spawnSync("bash", ["-c", script], { env: { ...process.env, PROBE: probe }, encoding: "utf8", timeout: 5000 }); + assert.ifError(result.error); + assert.equal(result.stdout, verdict); + } +}); + +test("real cache ownership probe passes for an agent-owned cache", async (t) => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-verify-cache-")); + t.after(() => rm(home, { recursive: true, force: true })); + const start = verify.indexOf('cache_probe="$(in_sbx '); + const end = verify.indexOf('\ninfo "7. Respawn supervisor', start); + assert.ok(start > 0 && end > start); + const check = verify.slice(start, end); + const script = `in_sbx() { bash -c "$1"; }\nok() { printf 'PASS'; }; bad() { printf 'FAIL'; }; skip() { printf 'SKIP'; };\n${check}`; + function verdict() { + const result = spawnSync("bash", ["-c", script], { + env: { ...process.env, HOME: home }, encoding: "utf8", timeout: 5000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + return result.stdout; + } + assert.equal(verdict(), "SKIP"); + await mkdir(path.join(home, ".npm")); + assert.equal(verdict(), "PASS"); + await writeFile(path.join(home, ".npm", "owned-cache-entry"), "fixture"); + assert.equal(verdict(), "PASS"); +}); + +test("host reachability is not credited without an in-guest identity baseline", () => { + const start = verify.indexOf(' if [ "$h_rc" -eq 0 ] && [ "$h_code" = "200" ]'); + const end = verify.indexOf('\n # BROWSER-ACCURATE check:', start); + assert.ok(start > 0 && end > start); + const check = verify.slice(start, end); + for (const [baseline, hostSize, verdict] of [["", "15000000", "SKIP"], ["15000000", "15000000", "PASS"], ["15000000", "14000000", "FAIL"]]) { + const script = `g_size="$1"; h_size="$2"; h_rc=0; h_code=200; h_out="200 $h_size"; g_stat="200 $g_size"; HOST_HP=127.0.0.1:6767; host_fetch_ok=""; ok() { printf 'PASS'; }; bad() { printf 'FAIL'; }; skip() { printf 'SKIP'; };\n${check}\n[ -z "$host_fetch_ok" ] || printf ':REACHABLE'`; + const result = spawnSync("bash", ["-c", script, "test", baseline, hostSize], { encoding: "utf8", timeout: 5000 }); + assert.ifError(result.error); + assert.equal(result.stdout, verdict === "PASS" ? "PASS:REACHABLE" : verdict); + } +}); + +test("guest identity baseline requires successful curl completion", () => { + const start = verify.indexOf(' g_stat="$(in_sbx "curl -fsS'); + const end = verify.indexOf(' g_code="${g_stat%% *}"', start); + assert.ok(start > 0 && end > start); + const probe = verify.slice(start, end); + for (const [response, expected] of [["200 15000000", ""], ["200 15000000 GUEST_FETCH_DONE", "200 15000000"]]) { + const script = `in_sbx() { printf '%s' "$RESPONSE"; };\n${probe}\nprintf 'result=%s' "$g_stat"`; + const result = spawnSync("bash", ["-c", script], { env: { ...process.env, RESPONSE: response }, encoding: "utf8", timeout: 5000 }); + assert.ifError(result.error); + assert.equal(result.stdout, `result=${expected}`); + } +});