From 554d0862101611aad82845da7ae6284c81923f87 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 14:35:45 -0400 Subject: [PATCH 01/21] fix(paseo): open real opencode outside PID 1 Keep the Paseo entrypoint shim only when the wrapper is PID 1. Bare opencode in an MSB shell or exec session opens the real terminal TUI without repinning the global Paseo worktree root. Update the verify contract and offline wrapper tests. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/README.md | 13 ++--- .../acq-kits/paseo/TROUBLESHOOTING.md | 5 +- .../decisions/paseo-mixin-generic-wrapper.md | 10 ++-- .../paseo/files/home/.local/bin/opencode | 22 +++++--- .../isolation/acq-kits/paseo/scripts/verify | 45 ++++++++++------ .../isolation/acq-kits/paseo/spec.yaml | 23 ++++---- .../tests/paseo-opencode-wrapper.test.mjs | 53 +++++++++++++++++++ 7 files changed, 123 insertions(+), 48 deletions(-) create mode 100644 integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs diff --git a/integrations/isolation/acq-kits/paseo/README.md b/integrations/isolation/acq-kits/paseo/README.md index d24bfb77..dde11d26 100644 --- a/integrations/isolation/acq-kits/paseo/README.md +++ b/integrations/isolation/acq-kits/paseo/README.md @@ -235,10 +235,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 +261,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. @@ -342,7 +343,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..8a45f846 100644 --- a/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md @@ -553,8 +553,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..72116b84 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,19 @@ 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): report status and return. The thin + wrapper no longer sends shell or `acq exec` invocations to this path. - Offers **no** TUI attach — there is no shared Paseo session to attach to (`paseo-single-port-daemon.md`). 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..a9359ff4 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 @@ -62,7 +59,16 @@ if [ "$#" -gt 0 ]; then exec "$real_opencode" "$@" fi -# --- No args: hand off to the generic shim (exec, to preserve PID for the hold). +# --- 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 + +# --- PID 1: hand off to the generic shim (exec preserves PID for the hold). -- if [ ! -f "$SHIM" ]; then echo "opencode wrapper: kit shim not found at $SHIM" >&2 exit 127 diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 15577021..bee3d9f5 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -626,22 +626,22 @@ 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 holds PID 1; bare opencode outside PID 1 opens the real TUI" # 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. +# (c) live: run the wrapper no-arg without a TTY (NOT PID 1) and confirm it +# does not print the Paseo connect banner or enter the PID-1 hold. 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)" + ok "thin wrapper execs the generic shim on the PID-1 path" else bad "thin wrapper does not exec the shim — no-arg path won't inherit PID 1, breaking the hold" fi @@ -661,21 +661,32 @@ else fi wrapper_probe="$(in_sbx ' if command -v timeout >/dev/null 2>&1; then - timeout 20 opencode /dev/null 2>&1 - echo "rc=$?" + before=$(cat "${PASEO_HOME:-$HOME/.paseo}/config.json" 2>/dev/null) + timeout 20 opencode &1 + echo "probe_rc=$?" + after=$(cat "${PASEO_HOME:-$HOME/.paseo}/config.json" 2>/dev/null) + if [ "$before" = "$after" ]; then echo "root_unchanged=yes"; else echo "root_unchanged=no"; fi else - echo "rc=notimeout" + echo "probe_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})" ;; + *"Connect from your HOST"*) bad "bare opencode outside PID 1 still runs the Paseo shim" ;; + *root_unchanged=no*) bad "bare opencode outside PID 1 changed Paseo configuration" ;; + *probe_rc=notimeout*) skip "could not run the wrapper probe (no 'timeout' in sandbox)" ;; + *probe_rc=124*) skip "bare OpenCode did not exit without a TTY; cannot verify passthrough here" ;; + *probe_rc=127*) bad "real OpenCode binary not found outside PID 1" ;; + *) + if printf '%s\n' "$wrapper_src" | grep -Fq 'exec "$real_opencode"'; then + ok "bare opencode outside PID 1 execs the real OpenCode binary" + else + bad "bare opencode exited without proof it execs the real binary" + fi ;; esac 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,14 +696,14 @@ 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")" +# Startup owns the initial root; querying must not change it. +pin_out="$(in_sbx '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 diff --git a/integrations/isolation/acq-kits/paseo/spec.yaml b/integrations/isolation/acq-kits/paseo/spec.yaml index c5310e54..3bdf1d4a 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,9 +255,9 @@ 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. 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..ed93be73 --- /dev/null +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, 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/); +}); From aa6d94f023159975b98e038cb1bc60881ad87720 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 14:53:30 -0400 Subject: [PATCH 02/21] fix(paseo): reject wrapper aliases and clarify entrypoint probe Resolve executable symlinks before choosing the real OpenCode binary. Keep non-PID-1 wrapper checks from claiming a TUI opened based on a non-TTY exit, and document direct-shim and startup-root limits accurately. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../acq-kits/paseo/TROUBLESHOOTING.md | 7 +++--- .../decisions/paseo-mixin-generic-wrapper.md | 6 +++-- .../paseo/files/home/.local/bin/opencode | 10 ++++++--- .../isolation/acq-kits/paseo/scripts/verify | 10 +++------ .../isolation/acq-kits/paseo/spec.yaml | 5 +++-- .../tests/paseo-opencode-wrapper.test.mjs | 22 ++++++++++++++++++- 6 files changed, 42 insertions(+), 18 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md index 8a45f846..b4ba0428 100644 --- a/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md +++ b/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md @@ -466,9 +466,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 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 72116b84..0602b4ab 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 @@ -38,8 +38,10 @@ agent-neutral shim.** discriminates on `$$`: - **PID 1** (the `acq run` entrypoint): `hold_pid1` — block forever so the session's sandbox stays up. - - **not PID 1** (if invoked directly): report status and return. The thin - wrapper no longer sends shell or `acq exec` invocations to this path. + - **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`). 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 a9359ff4..ba64a529 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode +++ b/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode @@ -34,15 +34,19 @@ SHIM="$HOME_DIR/paseo-agent-shim" # 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 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 real_opencode="$_cand" break done diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index bee3d9f5..f751de55 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -635,7 +635,8 @@ info "8. Entrypoint holds PID 1; bare opencode outside PID 1 opens the real TUI" # 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 without a TTY (NOT PID 1) and confirm it -# does not print the Paseo connect banner or enter the PID-1 hold. +# does not print the Paseo connect banner, enter PID-1 hold, or alter the +# daemon config. A non-TTY exit cannot prove the TUI opened: skip if so. 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 @@ -676,12 +677,7 @@ case "$wrapper_probe" in *probe_rc=notimeout*) skip "could not run the wrapper probe (no 'timeout' in sandbox)" ;; *probe_rc=124*) skip "bare OpenCode did not exit without a TTY; cannot verify passthrough here" ;; *probe_rc=127*) bad "real OpenCode binary not found outside PID 1" ;; - *) - if printf '%s\n' "$wrapper_src" | grep -Fq 'exec "$real_opencode"'; then - ok "bare opencode outside PID 1 execs the real OpenCode binary" - else - bad "bare opencode exited without proof it execs the real binary" - fi ;; + *) skip "non-TTY exit does not identify the invoked binary; offline wrapper test covers exec branch" ;; esac info "9. Worktree root pinned under the project + daemon self-heals — regression guards" diff --git a/integrations/isolation/acq-kits/paseo/spec.yaml b/integrations/isolation/acq-kits/paseo/spec.yaml index 3bdf1d4a..48578ac3 100644 --- a/integrations/isolation/acq-kits/paseo/spec.yaml +++ b/integrations/isolation/acq-kits/paseo/spec.yaml @@ -284,8 +284,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 index ed93be73..6bd432bb 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { 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"; @@ -51,3 +51,23 @@ test("a missing real opencode fails visibly outside PID 1", async (t) => { 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/); +}); From 3534cffc0f52f6b2c39e2b85beb5674f909f91dc Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 15:03:01 -0400 Subject: [PATCH 03/21] fix(paseo): fail verification on stale worktree roots Do not claim a non-TTY exit proves the real TUI launched or a source-level check proves the live PID-1 hold. Fail on a root unrelated to the primary guest workspace and describe the live observation still needed. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../acq-kits/paseo/files/home/.local/bin/opencode | 4 ++-- integrations/isolation/acq-kits/paseo/scripts/verify | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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 ba64a529..7ce20ff2 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode +++ b/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode @@ -53,8 +53,8 @@ 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 diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index f751de55..724b80e5 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -626,7 +626,7 @@ case "${daemon_sup:-0}" in *) bad "found ${daemon_sup} paseo-daemon supervisors (expected 1 — duplicate respawner?)" ;; esac -info "8. Entrypoint holds PID 1; bare opencode outside PID 1 opens the real TUI" +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 @@ -634,6 +634,7 @@ info "8. Entrypoint holds PID 1; bare opencode outside PID 1 opens the real TUI" # (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; +# these source checks do NOT demonstrate the live PID-1 handoff; # (c) live: run the wrapper no-arg without a TTY (NOT PID 1) and confirm it # does not print the Paseo connect banner, enter PID-1 hold, or alter the # daemon config. A non-TTY exit cannot prove the TUI opened: skip if so. @@ -646,6 +647,7 @@ elif printf '%s\n' "$wrapper_src" | grep -Eq 'exec[[:space:]]+sh[[:space:]]+"?\$ 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 @@ -700,13 +702,13 @@ if [ -z "$GUEST_WS" ]; then fi # Startup owns the initial root; querying must not change it. pin_out="$(in_sbx 'cat "${PASEO_HOME:-$HOME/.paseo}/config.json" 2>/dev/null')" -if printf '%s' "$pin_out" | grep -q "\"root\": \"$GUEST_WS/.paseo-worktrees\""; then +if printf '%s' "$pin_out" | grep -Fq "\"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. + # Startup may preserve an older user config. That is not proof that the + # primary workspace is used; do not turn an unrelated root into a 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'" + bad "worktrees.root ($_actual) differs from primary guest workspace '$GUEST_WS'" else bad "worktrees.root not pinned to any /.paseo-worktrees — config.json was:" printf '%s\n' "$pin_out" | sed 's/^/ | /' From 7ed9dbabcd906e11282cb79c3dad13af2ce07228 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 15:24:20 -0400 Subject: [PATCH 04/21] fix(paseo): verify the exact worktrees root field Reject a stale worktrees.root even if another JSON object contains a matching root. Label PID-1 source checks as structural rather than live evidence, and clarify direct shim invocation. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/README.md | 3 ++- .../paseo/files/home/paseo-agent-shim | 11 ++++---- .../isolation/acq-kits/paseo/scripts/verify | 21 +++++++-------- .../paseo/tests/paseo-verify.test.mjs | 27 ++++++++++++++++++- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/README.md b/integrations/isolation/acq-kits/paseo/README.md index dde11d26..e1dd2a90 100644 --- a/integrations/isolation/acq-kits/paseo/README.md +++ b/integrations/isolation/acq-kits/paseo/README.md @@ -321,7 +321,8 @@ 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 # Keep the sandbox afterward to inspect it: 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..93363c05 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim +++ b/integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim @@ -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 724b80e5..eca32034 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -643,7 +643,7 @@ 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 PID-1 path" + 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 @@ -652,12 +652,12 @@ 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 @@ -700,18 +700,17 @@ if [ -z "$GUEST_WS" ]; then GUEST_WS="$(in_sbx 'cat /var/lib/acq/workspace 2>/dev/null')" [ -n "$GUEST_WS" ] || GUEST_WS="$WORK" fi -# Startup owns the initial root; querying must not change it. -pin_out="$(in_sbx 'cat "${PASEO_HOME:-$HOME/.paseo}/config.json" 2>/dev/null')" -if printf '%s' "$pin_out" | grep -Fq "\"root\": \"$GUEST_WS/.paseo-worktrees\""; then +# Startup owns the initial root; read only worktrees.root, not an unrelated +# object with a matching root key. +pin_out="$(in_sbx "node -e 'try { const c = JSON.parse(require(\"fs\").readFileSync((process.env.PASEO_HOME || process.env.HOME + \"/.paseo\") + \"/config.json\", \"utf8\")); if (typeof c?.worktrees?.root === \"string\") process.stdout.write(c.worktrees.root); } catch {}'")" +if [ "$pin_out" = "$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 +elif [ -n "$pin_out" ]; then # Startup may preserve an older user config. That is not proof that the # primary workspace is used; do not turn an unrelated root into a PASS. - _actual="$(printf '%s' "$pin_out" | grep -Eo '"root": *"[^"]*/\.paseo-worktrees"' | head -1)" - bad "worktrees.root ($_actual) differs from primary guest workspace '$GUEST_WS'" + bad "worktrees.root ('$pin_out') differs from primary guest workspace '$GUEST_WS'" else - bad "worktrees.root not pinned to any /.paseo-worktrees — config.json was:" - printf '%s\n' "$pin_out" | sed 's/^/ | /' + bad "worktrees.root missing or config unreadable; expected '$GUEST_WS/.paseo-worktrees'" fi # (b) daemon self-heal: if the daemon dies, supervisor:paseo-daemon must relaunch 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..78027ce9 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, 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() {"); @@ -38,3 +40,26 @@ 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}\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(), "/wrong/.paseo-worktrees"); + await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/.paseo-worktrees" } })); + assert.equal(root(), "/primary/.paseo-worktrees"); + await writeFile(config, "{invalid"); + assert.equal(root(), ""); +}); From 384e37db6bb6a3a47ddda04bd79db43a1e776abb Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 16:17:58 -0400 Subject: [PATCH 05/21] fix(paseo): avoid running the real TUI in verification Treat PID-1 source checks as structural evidence and leave live behavior explicitly unverified. Assert the verdict for the exact nested worktrees.root field, including an unrelated matching-key negative fixture. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 49 +++++++------------ .../paseo/tests/paseo-verify.test.mjs | 14 ++++++ 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index eca32034..11828d06 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. @@ -635,9 +636,9 @@ info "8. Entrypoint structure and non-PID-1 behavior (PID-1 runtime requires a s # 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; # these source checks do NOT demonstrate the live PID-1 handoff; -# (c) live: run the wrapper no-arg without a TTY (NOT PID 1) and confirm it -# does not print the Paseo connect banner, enter PID-1 hold, or alter the -# daemon config. A non-TTY exit cannot prove the TUI opened: skip if so. +# (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 @@ -662,25 +663,7 @@ 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 - before=$(cat "${PASEO_HOME:-$HOME/.paseo}/config.json" 2>/dev/null) - timeout 20 opencode &1 - echo "probe_rc=$?" - after=$(cat "${PASEO_HOME:-$HOME/.paseo}/config.json" 2>/dev/null) - if [ "$before" = "$after" ]; then echo "root_unchanged=yes"; else echo "root_unchanged=no"; fi - else - echo "probe_rc=notimeout" - fi -')" -case "$wrapper_probe" in - *"Connect from your HOST"*) bad "bare opencode outside PID 1 still runs the Paseo shim" ;; - *root_unchanged=no*) bad "bare opencode outside PID 1 changed Paseo configuration" ;; - *probe_rc=notimeout*) skip "could not run the wrapper probe (no 'timeout' in sandbox)" ;; - *probe_rc=124*) skip "bare OpenCode did not exit without a TTY; cannot verify passthrough here" ;; - *probe_rc=127*) bad "real OpenCode binary not found outside PID 1" ;; - *) skip "non-TTY exit does not identify the invoked binary; offline wrapper test covers exec branch" ;; -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: the startup-seeded config should retain the primary @@ -702,16 +685,18 @@ if [ -z "$GUEST_WS" ]; then 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" actual="$2" + if [ "$actual" = "$expected" ]; then + ok "worktrees.root pinned under the project ($expected)" + elif [ -n "$actual" ]; then + bad "worktrees.root ('$actual') differs from primary guest workspace '$expected'" + else + bad "worktrees.root missing or config unreadable; expected '$expected'" + fi +} pin_out="$(in_sbx "node -e 'try { const c = JSON.parse(require(\"fs\").readFileSync((process.env.PASEO_HOME || process.env.HOME + \"/.paseo\") + \"/config.json\", \"utf8\")); if (typeof c?.worktrees?.root === \"string\") process.stdout.write(c.worktrees.root); } catch {}'")" -if [ "$pin_out" = "$GUEST_WS/.paseo-worktrees" ]; then - ok "worktrees.root pinned under the project ($GUEST_WS/.paseo-worktrees)" -elif [ -n "$pin_out" ]; then - # Startup may preserve an older user config. That is not proof that the - # primary workspace is used; do not turn an unrelated root into a PASS. - bad "worktrees.root ('$pin_out') differs from primary guest workspace '$GUEST_WS'" -else - bad "worktrees.root missing or config unreadable; expected '$GUEST_WS/.paseo-worktrees'" -fi +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 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 78027ce9..db9b4044 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -9,6 +9,10 @@ const verify = await readFile(new URL("../scripts/verify", import.meta.url), "ut 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`], { @@ -58,8 +62,18 @@ test("worktree check reads the exact nested root, not a matching unrelated field } await writeFile(config, JSON.stringify({ other: { root: "/primary/.paseo-worktrees" }, worktrees: { root: "/wrong/.paseo-worktrees" } })); assert.equal(root(), "/wrong/.paseo-worktrees"); + function verdict() { + const result = spawnSync("bash", ["-c", `${rootCheck}\nfail=0; ok() { printf 'PASS:%s\\n' "$1"; }; bad() { fail=$((fail+1)); printf 'FAIL:%s\\n' "$1"; }; check_worktrees_root "/primary/.paseo-worktrees" "${root()}"; exit "$fail"`], { encoding: "utf8", timeout: 5000 }); + assert.ifError(result.error); + return result; + } + assert.equal(verdict().status, 1); + assert.match(verdict().stdout, /FAIL:worktrees.root/); await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/.paseo-worktrees" } })); assert.equal(root(), "/primary/.paseo-worktrees"); + assert.equal(verdict().status, 0); + assert.match(verdict().stdout, /PASS:worktrees.root/); await writeFile(config, "{invalid"); assert.equal(root(), ""); + assert.equal(verdict().status, 1); }); From 139f15ca17d9d14f8200053952fa405738e82beb Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 16:28:12 -0400 Subject: [PATCH 06/21] docs(paseo): distinguish startup seeding from PID-1 hold Document conditional worktree-root initialization and state the detached verifier checks source structure, not a live PID-1 handoff. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../docs/decisions/paseo-mixin-generic-wrapper.md | 11 ++++++----- .../docs/decisions/worktrees-root-global-only.md | 9 ++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) 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 0602b4ab..538349d7 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 @@ -51,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 @@ -88,9 +89,9 @@ 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 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 From 37b124998813e467caf798a2c3c95e8eefa315ff Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 16:38:31 -0400 Subject: [PATCH 07/21] fix(paseo): qualify detached verification outcomes Mark the PID-1 hold and real TUI as unmeasured in the detached verifier. Distinguish unavailable startup seeding from an incorrect seeded root, with positive and negative verdict fixtures. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../decisions/paseo-mixin-generic-wrapper.md | 2 +- .../acq-kits/paseo/files/home/paseo-agent-shim | 2 +- .../isolation/acq-kits/paseo/scripts/verify | 18 ++++++++++++++---- .../acq-kits/paseo/tests/paseo-verify.test.mjs | 6 ++++-- 4 files changed, 20 insertions(+), 8 deletions(-) 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 538349d7..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 @@ -95,7 +95,7 @@ mechanical step rather than speculative code we don't exercise. - 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/files/home/paseo-agent-shim b/integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim index 93363c05..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. diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 11828d06..78ef21f7 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -88,9 +88,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"; } @@ -686,9 +687,11 @@ 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" actual="$2" + local expected="$1" actual="$2" seed_available="$3" if [ "$actual" = "$expected" ]; then ok "worktrees.root pinned under the project ($expected)" + elif [ "$seed_available" != yes ]; then + skip "startup had no usable ACQ_WORKSPACE; pre-attach worktree root is unverified" elif [ -n "$actual" ]; then bad "worktrees.root ('$actual') differs from primary guest workspace '$expected'" else @@ -696,7 +699,8 @@ check_worktrees_root() { fi } pin_out="$(in_sbx "node -e 'try { const c = JSON.parse(require(\"fs\").readFileSync((process.env.PASEO_HOME || process.env.HOME + \"/.paseo\") + \"/config.json\", \"utf8\")); if (typeof c?.worktrees?.root === \"string\") process.stdout.write(c.worktrees.root); } catch {}'")" -check_worktrees_root "$GUEST_WS/.paseo-worktrees" "$pin_out" +seed_available="$(in_sbx 'case "${ACQ_WORKSPACE:-}" in /*) if [ -d "$ACQ_WORKSPACE" ]; then printf yes; fi ;; esac')" +check_worktrees_root "$GUEST_WS/.paseo-worktrees" "$pin_out" "$seed_available" # (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 @@ -870,6 +874,12 @@ 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 ] 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 db9b4044..777c1ac0 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -62,13 +62,15 @@ test("worktree check reads the exact nested root, not a matching unrelated field } await writeFile(config, JSON.stringify({ other: { root: "/primary/.paseo-worktrees" }, worktrees: { root: "/wrong/.paseo-worktrees" } })); assert.equal(root(), "/wrong/.paseo-worktrees"); - function verdict() { - const result = spawnSync("bash", ["-c", `${rootCheck}\nfail=0; ok() { printf 'PASS:%s\\n' "$1"; }; bad() { fail=$((fail+1)); printf 'FAIL:%s\\n' "$1"; }; check_worktrees_root "/primary/.paseo-worktrees" "${root()}"; exit "$fail"`], { encoding: "utf8", timeout: 5000 }); + function verdict(seedAvailable = "yes") { + const result = spawnSync("bash", ["-c", `${rootCheck}\nfail=0; ok() { printf 'PASS:%s\\n' "$1"; }; bad() { fail=$((fail+1)); printf 'FAIL:%s\\n' "$1"; }; skip() { printf 'SKIP:%s\\n' "$1"; }; check_worktrees_root "/primary/.paseo-worktrees" "${root()}" "${seedAvailable}"; exit "$fail"`], { encoding: "utf8", timeout: 5000 }); assert.ifError(result.error); return result; } assert.equal(verdict().status, 1); assert.match(verdict().stdout, /FAIL:worktrees.root/); + assert.equal(verdict("no").status, 0); + assert.match(verdict("no").stdout, /SKIP:startup had no usable ACQ_WORKSPACE/); await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/.paseo-worktrees" } })); assert.equal(root(), "/primary/.paseo-worktrees"); assert.equal(verdict().status, 0); From cbd51a72cb1530a385e3fe2fd66e0b1a35be5ea6 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 16:48:19 -0400 Subject: [PATCH 08/21] fix(paseo): qualify preserved roots and skipped checks Do not infer startup seeding solely from a usable workspace. Distinguish a preserved or unverified root from a measured mismatch, and qualify offline results when required validators were skipped. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 14 ++++++++++---- .../acq-kits/paseo/tests/paseo-verify.test.mjs | 8 +++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 78ef21f7..6c7f6325 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -231,7 +231,13 @@ if [ -z "$DRIVER" ]; then 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' + 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 exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" fi @@ -689,11 +695,11 @@ fi check_worktrees_root() { local expected="$1" actual="$2" seed_available="$3" if [ "$actual" = "$expected" ]; then - ok "worktrees.root pinned under the project ($expected)" + ok "worktrees.root equals the primary guest workspace ($expected); seed provenance unverified" + elif [ -n "$actual" ]; then + skip "worktrees.root ('$actual') differs from '$expected'; startup may have preserved existing config" elif [ "$seed_available" != yes ]; then skip "startup had no usable ACQ_WORKSPACE; pre-attach worktree root is unverified" - elif [ -n "$actual" ]; then - bad "worktrees.root ('$actual') differs from primary guest workspace '$expected'" else bad "worktrees.root missing or config unreadable; expected '$expected'" fi 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 777c1ac0..d13d8e89 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -67,10 +67,10 @@ test("worktree check reads the exact nested root, not a matching unrelated field assert.ifError(result.error); return result; } - assert.equal(verdict().status, 1); - assert.match(verdict().stdout, /FAIL:worktrees.root/); + assert.equal(verdict().status, 0); + assert.match(verdict().stdout, /SKIP:worktrees.root/); assert.equal(verdict("no").status, 0); - assert.match(verdict("no").stdout, /SKIP:startup had no usable ACQ_WORKSPACE/); + assert.match(verdict("no").stdout, /SKIP:worktrees.root/); await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/.paseo-worktrees" } })); assert.equal(root(), "/primary/.paseo-worktrees"); assert.equal(verdict().status, 0); @@ -78,4 +78,6 @@ test("worktree check reads the exact nested root, not a matching unrelated field await writeFile(config, "{invalid"); assert.equal(root(), ""); assert.equal(verdict().status, 1); + assert.equal(verdict("no").status, 0); + assert.match(verdict("no").stdout, /SKIP:startup had no usable ACQ_WORKSPACE/); }); From 594414ec66888e30887af6198149a55991d767fa Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 16:56:22 -0400 Subject: [PATCH 09/21] fix(paseo): return unverified when live checks are skipped Separate valid, missing, and invalid config results in the detached verifier. Report preserved roots as unverified, fail invalid data, and give skipped checks a distinct exit status instead of a verified pass. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 39 +++++++++++-------- .../paseo/tests/paseo-verify.test.mjs | 20 +++++----- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 6c7f6325..3f10b291 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -227,6 +227,7 @@ 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" @@ -238,11 +239,14 @@ if [ -z "$DRIVER" ]; then else printf ' \033[32mOffline gate passed.\033[0m\n' fi - exit "$([ "$fail" -eq 0 ] && echo 0 || echo 1)" + [ "$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 + 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 1 fi echo " driver: $DRIVER" @@ -326,14 +330,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 @@ -693,20 +698,19 @@ 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" actual="$2" seed_available="$3" - if [ "$actual" = "$expected" ]; then + local expected="$1" observed="$2" + if [ "$observed" = "valid:$expected" ]; then ok "worktrees.root equals the primary guest workspace ($expected); seed provenance unverified" - elif [ -n "$actual" ]; then - skip "worktrees.root ('$actual') differs from '$expected'; startup may have preserved existing config" - elif [ "$seed_available" != yes ]; then - skip "startup had no usable ACQ_WORKSPACE; pre-attach worktree root is unverified" - else - bad "worktrees.root missing or config unreadable; expected '$expected'" + 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" ;; + *) bad "Paseo config is unreadable, invalid JSON, or has no valid worktrees.root" ;; + esac } -pin_out="$(in_sbx "node -e 'try { const c = JSON.parse(require(\"fs\").readFileSync((process.env.PASEO_HOME || process.env.HOME + \"/.paseo\") + \"/config.json\", \"utf8\")); if (typeof c?.worktrees?.root === \"string\") process.stdout.write(c.worktrees.root); } catch {}'")" -seed_available="$(in_sbx 'case "${ACQ_WORKSPACE:-}" in /*) if [ -d "$ACQ_WORKSPACE" ]; then printf yes; fi ;; esac')" -check_worktrees_root "$GUEST_WS/.paseo-worktrees" "$pin_out" "$seed_available" +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\")); process.stdout.write(typeof c?.worktrees?.root === \"string\" && c.worktrees.root.length ? \"valid:\" + c.worktrees.root : \"invalid\"); } catch (e) { process.stdout.write(e.code === \"ENOENT\" ? \"missing\" : \"invalid\"); }'")" +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 @@ -888,4 +892,5 @@ 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/tests/paseo-verify.test.mjs b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs index d13d8e89..00e59486 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -61,23 +61,23 @@ test("worktree check reads the exact nested root, not a matching unrelated field return result.stdout; } await writeFile(config, JSON.stringify({ other: { root: "/primary/.paseo-worktrees" }, worktrees: { root: "/wrong/.paseo-worktrees" } })); - assert.equal(root(), "/wrong/.paseo-worktrees"); - function verdict(seedAvailable = "yes") { - const result = spawnSync("bash", ["-c", `${rootCheck}\nfail=0; ok() { printf 'PASS:%s\\n' "$1"; }; bad() { fail=$((fail+1)); printf 'FAIL:%s\\n' "$1"; }; skip() { printf 'SKIP:%s\\n' "$1"; }; check_worktrees_root "/primary/.paseo-worktrees" "${root()}" "${seedAvailable}"; exit "$fail"`], { encoding: "utf8", timeout: 5000 }); + 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, 0); + assert.equal(verdict().status, 3); assert.match(verdict().stdout, /SKIP:worktrees.root/); - assert.equal(verdict("no").status, 0); - assert.match(verdict("no").stdout, /SKIP:worktrees.root/); await writeFile(config, JSON.stringify({ worktrees: { root: "/primary/.paseo-worktrees" } })); - assert.equal(root(), "/primary/.paseo-worktrees"); + assert.equal(root(), "valid:/primary/.paseo-worktrees"); assert.equal(verdict().status, 0); assert.match(verdict().stdout, /PASS:worktrees.root/); await writeFile(config, "{invalid"); - assert.equal(root(), ""); + assert.equal(root(), "invalid"); assert.equal(verdict().status, 1); - assert.equal(verdict("no").status, 0); - assert.match(verdict("no").stdout, /SKIP:startup had no usable ACQ_WORKSPACE/); + await rm(config); + assert.equal(root(), "missing"); + assert.equal(verdict().status, 3); + assert.match(verdict().stdout, /SKIP:no config exists/); }); From 173ad4496a253c12038e3ab2eef691c1910d7b50 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 17:11:49 -0400 Subject: [PATCH 10/21] fix(paseo): distinguish guest probe failure from bad config Classify transport failure as unverified, reject malformed or relative worktree roots, and document the detached verifier exit-3 contract. Cover the typed outcomes with offline fixtures. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- integrations/isolation/acq-kits/paseo/README.md | 5 +++++ .../decisions/ui-readiness-gate-immutable-cache.md | 4 +++- .../isolation/acq-kits/paseo/scripts/verify | 4 +++- .../acq-kits/paseo/tests/paseo-verify.test.mjs | 13 ++++++++++++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/README.md b/integrations/isolation/acq-kits/paseo/README.md index e1dd2a90..57303889 100644 --- a/integrations/isolation/acq-kits/paseo/README.md +++ b/integrations/isolation/acq-kits/paseo/README.md @@ -325,6 +325,11 @@ python ../validate-kits.py # 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 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/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 3f10b291..1a8cd21f 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -706,10 +706,12 @@ check_worktrees_root() { 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\")); process.stdout.write(typeof c?.worktrees?.root === \"string\" && c.worktrees.root.length ? \"valid:\" + c.worktrees.root : \"invalid\"); } catch (e) { process.stdout.write(e.code === \"ENOENT\" ? \"missing\" : \"invalid\"); }'")" +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\" && /^\\/[^\\s]+$/.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 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 00e59486..b99b8b2f 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -53,7 +53,7 @@ test("worktree check reads the exact nested root, not a matching unrelated field 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}\nprintf '%s' "$pin_out"`], { + 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); @@ -76,8 +76,19 @@ test("worktree check reads the exact nested root, not a matching unrelated field 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 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"); }); From 8bbe431eb102724d8c26bee407ae4a52b8276d6b Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 17:39:30 -0400 Subject: [PATCH 11/21] fix(paseo): validate root encoding without rejecting spaces Accept usable absolute workspace paths with internal spaces and reject JSON control characters before shell transport can discard them. Correct detached lifecycle and backend-dependent host-port guidance. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/README.md | 21 ++++++++----------- .../isolation/acq-kits/paseo/scripts/verify | 2 +- .../paseo/tests/paseo-verify.test.mjs | 5 +++++ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/README.md b/integrations/isolation/acq-kits/paseo/README.md index 57303889..96721401 100644 --- a/integrations/isolation/acq-kits/paseo/README.md +++ b/integrations/isolation/acq-kits/paseo/README.md @@ -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 diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 1a8cd21f..82e27327 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -710,7 +710,7 @@ check_worktrees_root() { *) 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\" && /^\\/[^\\s]+$/.test(root) ? \"valid:\" + root : \"invalid\"); } catch (e) { process.stdout.write(e.code === \"ENOENT\" ? \"missing\" : \"invalid\"); }' && printf PROBE_DONE")" +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" 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 b99b8b2f..fcba0578 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -73,6 +73,8 @@ test("worktree check reads the exact nested root, not a matching unrelated field assert.equal(root(), "valid:/primary/.paseo-worktrees"); assert.equal(verdict().status, 0); assert.match(verdict().stdout, /PASS: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); @@ -82,6 +84,9 @@ test("worktree check reads the exact nested root, not a matching unrelated field 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); From 7b1bb3278c3fdea7641c56d3c6ae65ca87df21c5 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 17:52:03 -0400 Subject: [PATCH 12/21] fix(paseo): compare registered project paths literally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep space-containing paths intact in the verifier, align host-port guidance with backend allocation, and document Paseo’s separate incomplete-coverage exit status. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- integrations/isolation/acq-kits/README.md | 8 +++++--- integrations/isolation/acq-kits/paseo/README.md | 4 +++- .../isolation/acq-kits/paseo/scripts/verify | 17 ++++++++--------- integrations/isolation/acq-kits/paseo/spec.yaml | 6 ++++-- 4 files changed, 20 insertions(+), 15 deletions(-) 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 96721401..c535a3ae 100644 --- a/integrations/isolation/acq-kits/paseo/README.md +++ b/integrations/isolation/acq-kits/paseo/README.md @@ -185,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 diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 82e27327..00b89ec4 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -793,8 +793,6 @@ 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")" # Expected set: qualifying rw virtiofs mounts, expanded by the actual registrar @@ -831,9 +829,10 @@ 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 + printf '%s\n' "$project_roots" | grep -Fqx -- "$m" || missing="${missing}${missing:+, }$m" + done <<< "$expected_projects" [ -z "$missing" ] && break [ "$(date +%s)" -ge "$reg_deadline" ] && break sleep "$POLL_INTERVAL" @@ -847,8 +846,8 @@ 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 + if printf '%s\n' "$project_roots" | grep -Fqx -- "$RW_CHILD_REPO_A" && \ + printf '%s\n' "$project_roots" | grep -Fqx -- "$RW_CHILD_REPO_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" @@ -856,7 +855,7 @@ 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 printf '%s\n' "$project_roots" | grep -Fqx -- "$RW_FIXTURE"; 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" @@ -867,7 +866,7 @@ 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 printf '%s\n' "$project_roots" | grep -Fqx -- "$RO_FIXTURE"; then bad "read-only fixture ($ro_base) was wrongly registered (should be skipped)" else ok "read-only fixture ($ro_base) correctly skipped" diff --git a/integrations/isolation/acq-kits/paseo/spec.yaml b/integrations/isolation/acq-kits/paseo/spec.yaml index 48578ac3..150e4cb2 100644 --- a/integrations/isolation/acq-kits/paseo/spec.yaml +++ b/integrations/isolation/acq-kits/paseo/spec.yaml @@ -263,8 +263,10 @@ agentContext: | 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 From 01734d13941e00bff2a6716b4338565d656a9707 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 18:01:11 -0400 Subject: [PATCH 13/21] fix(paseo): dispatch PID-1 shim before binary discovery Do not require readlink or the real OpenCode binary on the PID-1 entrypoint path. Clarify detached sandbox behavior and cover branch ordering with a regression test. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/README.md | 8 ++++---- .../acq-kits/paseo/TROUBLESHOOTING.md | 12 +++++------- .../paseo/files/home/.local/bin/opencode | 18 +++++++++++------- .../tests/paseo-opencode-wrapper.test.mjs | 8 ++++++++ 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/README.md b/integrations/isolation/acq-kits/paseo/README.md index c535a3ae..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: diff --git a/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md b/integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md index b4ba0428..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) 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 7ce20ff2..49537296 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode +++ b/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode @@ -30,6 +30,17 @@ 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. @@ -71,10 +82,3 @@ if [ "$$" -ne 1 ]; then fi exec "$real_opencode" fi - -# --- PID 1: hand off to the generic shim (exec preserves PID for the hold). -- -if [ ! -f "$SHIM" ]; then - echo "opencode wrapper: kit shim not found at $SHIM" >&2 - exit 127 -fi -exec sh "$SHIM" 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 index 6bd432bb..a20de5bb 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs @@ -71,3 +71,11 @@ test("an alias-only PATH fails visibly without recursion", async (t) => { assert.equal(result.status, 127); assert.match(result.stderr, /could not locate the real opencode binary/); }); + +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"/); +}); From 96e853a9c3fe826cc7c5912ae9f92d86914954d9 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 18:17:21 -0400 Subject: [PATCH 14/21] fix(paseo): avoid hardlink recursion and compare guest project paths Reject a second PATH name for the wrapper inode and compare registered projects using physical paths resolved in the guest. Keep configured root equality distinct from physical containment evidence. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../paseo/files/home/.local/bin/opencode | 4 +++ .../isolation/acq-kits/paseo/scripts/verify | 23 +++++++++++++---- .../tests/paseo-opencode-wrapper.test.mjs | 12 ++++++++- .../paseo/tests/paseo-verify.test.mjs | 25 +++++++++++++++++-- 4 files changed, 56 insertions(+), 8 deletions(-) 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 49537296..14068f34 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode +++ b/integrations/isolation/acq-kits/paseo/files/home/.local/bin/opencode @@ -50,6 +50,8 @@ self_dir="$HOME_DIR/.local/bin" # 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 @@ -58,6 +60,8 @@ for _dir in $PATH; do { [ -x "$_cand" ] && [ ! -d "$_cand" ]; } || 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 diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 00b89ec4..95641ef4 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -700,7 +700,7 @@ fi check_worktrees_root() { local expected="$1" observed="$2" if [ "$observed" = "valid:$expected" ]; then - ok "worktrees.root equals the primary guest workspace ($expected); seed provenance unverified" + ok "configured worktrees.root equals the primary guest workspace ($expected); physical containment and seed provenance unverified" return fi case "$observed" in @@ -846,8 +846,17 @@ else fi # POSITIVE: child repos under the read-write parent fixture must be registered. - if printf '%s\n' "$project_roots" | grep -Fqx -- "$RW_CHILD_REPO_A" && \ - printf '%s\n' "$project_roots" | grep -Fqx -- "$RW_CHILD_REPO_B"; then + # Quote the host spelling as one shell operand, then obtain its *physical* + # guest spelling before comparing it with the API's projectRootPath. + quote_guest_path() { printf "'%s'" "${1//\'/\'\\\'\'}"; } + guest_physical_path() { in_sbx "cd $(quote_guest_path "$1") 2>/dev/null && pwd -P"; } + guest_rw_a="$(guest_physical_path "$RW_CHILD_REPO_A")" + guest_rw_b="$(guest_physical_path "$RW_CHILD_REPO_B")" + guest_rw_parent="$(guest_physical_path "$RW_FIXTURE")" + guest_ro="$(guest_physical_path "$RO_FIXTURE")" + if [ -n "$guest_rw_a" ] && [ -n "$guest_rw_b" ] && + printf '%s\n' "$project_roots" | grep -Fqx -- "$guest_rw_a" && + printf '%s\n' "$project_roots" | grep -Fqx -- "$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" @@ -855,7 +864,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 -Fqx -- "$RW_FIXTURE"; then + if [ -z "$guest_rw_parent" ]; then + skip "read-write parent mount not resolvable in guest; expansion unverified" + elif printf '%s\n' "$project_roots" | grep -Fqx -- "$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" @@ -866,7 +877,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 -Fqx -- "$RO_FIXTURE"; then + if [ -z "$guest_ro" ]; then + skip "read-only mount not resolvable in guest; exclusion unverified" + elif printf '%s\n' "$project_roots" | grep -Fqx -- "$guest_ro"; then bad "read-only fixture ($ro_base) was wrongly registered (should be skipped)" else ok "read-only fixture ($ro_base) correctly skipped" 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 index a20de5bb..860ec419 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-opencode-wrapper.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +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"; @@ -72,6 +72,16 @@ test("an alias-only PATH fails visibly without recursion", async (t) => { 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'); 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 fcba0578..5db38350 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, rm, writeFile } 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"; @@ -72,7 +72,7 @@ test("worktree check reads the exact nested root, not a matching unrelated field 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:worktrees.root/); + 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"); @@ -97,3 +97,24 @@ test("worktree check reads the exact nested root, not a matching unrelated field 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, last).join("\n"); + const script = `${helpers}\nin_sbx() { bash -c "$1"; }\nexpected=$(guest_physical_path "$1")\n[ "$expected" = "$2" ] || exit 1\nprintf '%s\\n' "$2" | grep -Fqx -- "$expected"`; + const result = spawnSync("bash", ["-c", script, "test", project, path.join(physical, "a space", "it's-repo")], { + encoding: "utf8", timeout: 5000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); +}); From f0026fb798baed2210ea8fc60baf09798e7f0e22 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 23:19:11 -0400 Subject: [PATCH 15/21] fix(paseo): accept registered guest path spellings Match either the guest-visible mount path or its physical guest spelling when checking fixture projects; require the fixture to resolve before accepting a registration. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 18 +++++++++++------- .../acq-kits/paseo/tests/paseo-verify.test.mjs | 15 +++++++++------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 95641ef4..9edbbfe0 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -846,17 +846,21 @@ else fi # POSITIVE: child repos under the read-write parent fixture must be registered. - # Quote the host spelling as one shell operand, then obtain its *physical* - # guest spelling before comparing it with the API's projectRootPath. + # The registrar submits mount paths as spelled in /proc/mounts. Paseo may + # return that spelling or its physical equivalent in projectRootPath. + # Resolve inside the guest, but accept only these two exact spellings. quote_guest_path() { printf "'%s'" "${1//\'/\'\\\'\'}"; } guest_physical_path() { in_sbx "cd $(quote_guest_path "$1") 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"; } + } guest_rw_a="$(guest_physical_path "$RW_CHILD_REPO_A")" guest_rw_b="$(guest_physical_path "$RW_CHILD_REPO_B")" guest_rw_parent="$(guest_physical_path "$RW_FIXTURE")" guest_ro="$(guest_physical_path "$RO_FIXTURE")" - if [ -n "$guest_rw_a" ] && [ -n "$guest_rw_b" ] && - printf '%s\n' "$project_roots" | grep -Fqx -- "$guest_rw_a" && - printf '%s\n' "$project_roots" | grep -Fqx -- "$guest_rw_b"; then + 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" @@ -866,7 +870,7 @@ else # were found and registered instead. if [ -z "$guest_rw_parent" ]; then skip "read-write parent mount not resolvable in guest; expansion unverified" - elif printf '%s\n' "$project_roots" | grep -Fqx -- "$guest_rw_parent"; then + 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" @@ -879,7 +883,7 @@ else if [ "$ro_is_ro" = "yes" ]; then if [ -z "$guest_ro" ]; then skip "read-only mount not resolvable in guest; exclusion unverified" - elif printf '%s\n' "$project_roots" | grep -Fqx -- "$guest_ro"; then + 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" 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 5db38350..b8cb6d8b 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -111,10 +111,13 @@ test("project comparison keeps spaced and canonicalized guest paths literal", as 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, last).join("\n"); - const script = `${helpers}\nin_sbx() { bash -c "$1"; }\nexpected=$(guest_physical_path "$1")\n[ "$expected" = "$2" ] || exit 1\nprintf '%s\\n' "$2" | grep -Fqx -- "$expected"`; - const result = spawnSync("bash", ["-c", script, "test", project, path.join(physical, "a space", "it's-repo")], { - encoding: "utf8", timeout: 5000, - }); - assert.ifError(result.error); - assert.equal(result.status, 0, result.stderr); + 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); + } }); From faf16737c09d20007db7ff2422c128b131a1e9c7 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 23:25:12 -0400 Subject: [PATCH 16/21] fix(paseo): match aggregate project paths in guest Apply the literal-or-physical guest path comparison to the complete project set and execute the aggregate loop against alias and unrelated path fixtures. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 23 +++++++++++-------- .../paseo/tests/paseo-verify.test.mjs | 14 ++++++++++- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 9edbbfe0..b8bb95c2 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -795,6 +795,16 @@ else rw_base="$(basename "$RW_FIXTURE")" 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//\'/\'\\\'\'}"; } + guest_physical_path() { in_sbx "cd $(quote_guest_path "$1") 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 ' @@ -831,7 +841,8 @@ else missing="" while IFS= read -r m; do [ -n "$m" ] || continue - printf '%s\n' "$project_roots" | grep -Fqx -- "$m" || missing="${missing}${missing:+, }$m" + 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 @@ -846,15 +857,7 @@ else fi # POSITIVE: child repos under the read-write parent fixture must be registered. - # The registrar submits mount paths as spelled in /proc/mounts. Paseo may - # return that spelling or its physical equivalent in projectRootPath. - # Resolve inside the guest, but accept only these two exact spellings. - quote_guest_path() { printf "'%s'" "${1//\'/\'\\\'\'}"; } - guest_physical_path() { in_sbx "cd $(quote_guest_path "$1") 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"; } - } + # Compare the child fixtures by the same rule as the aggregate set. guest_rw_a="$(guest_physical_path "$RW_CHILD_REPO_A")" guest_rw_b="$(guest_physical_path "$RW_CHILD_REPO_B")" guest_rw_parent="$(guest_physical_path "$RW_FIXTURE")" 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 b8cb6d8b..d94613b8 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -110,7 +110,7 @@ test("project comparison keeps spaced and canonicalized guest paths literal", as 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, last).join("\n"); + 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]]) { @@ -120,4 +120,16 @@ test("project comparison keeps spaced and canonicalized guest paths literal", as 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); + } }); From 9642a7e8b443e241aa0dad2575780594f49bfedb Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 23:39:56 -0400 Subject: [PATCH 17/21] fix(paseo): resolve host fixture aliases before guest checks Canonicalize host-owned verification fixtures before resolving their guest paths so a host /var alias does not falsely fail when the guest mounts /private/var. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 19 ++++++++++++++----- .../paseo/tests/paseo-verify.test.mjs | 9 +++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index b8bb95c2..3e69339a 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -799,7 +799,16 @@ else # 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//\'/\'\\\'\'}"; } - guest_physical_path() { in_sbx "cd $(quote_guest_path "$1") 2>/dev/null && pwd -P"; } + 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"; } @@ -858,10 +867,10 @@ else # POSITIVE: child repos under the read-write parent fixture must be registered. # Compare the child fixtures by the same rule as the aggregate set. - guest_rw_a="$(guest_physical_path "$RW_CHILD_REPO_A")" - guest_rw_b="$(guest_physical_path "$RW_CHILD_REPO_B")" - guest_rw_parent="$(guest_physical_path "$RW_FIXTURE")" - guest_ro="$(guest_physical_path "$RO_FIXTURE")" + 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" 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 d94613b8..f244cbb0 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -132,4 +132,13 @@ test("project comparison keeps spaced and canonicalized guest paths literal", as 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); }); From d3757a56d863c5668a108d44a19fd9bbe6bff3df Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 23:54:09 -0400 Subject: [PATCH 18/21] fix(paseo): fail closed on incomplete verifier probes Validate the guest port override before command construction, return unverified for absent live tooling or incomplete bundle and ownership probes, and exercise the verdict branches offline. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 31 +++++++++++++------ .../paseo/tests/paseo-verify.test.mjs | 29 +++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 3e69339a..821c2463 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -74,6 +74,14 @@ 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))" 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. @@ -247,7 +255,7 @@ fi if ! command -v "$DRIVER" >/dev/null 2>&1; then 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 1 + exit 3 fi echo " driver: $DRIVER" @@ -476,7 +484,7 @@ 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")" @@ -484,7 +492,7 @@ else 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. @@ -621,12 +629,17 @@ 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 owner=$(id -u) || exit; result=$(find "$HOME/.npm" -not -user "$owner" -print -quit 2>/dev/null) || exit; printf "%s\n" "$result"; fi; printf CACHE_PROBE_DONE')" +case "$cache_probe" in + *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 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 f244cbb0..3c91a4d0 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -142,3 +142,32 @@ test("project comparison keeps spaced and canonicalized guest paths literal", as 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"], ["00080", 0, "80"], ["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_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); + } +}); From e4d66527056da90145acd7b2366682ca310a8496 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 29 Oct 2026 23:59:28 -0400 Subject: [PATCH 19/21] fix(paseo): require guest baseline for host reachability Record an absent cache as unverified and only credit host bundle delivery when it matches an in-guest baseline. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 10 ++++++---- .../acq-kits/paseo/tests/paseo-verify.test.mjs | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 821c2463..a43cc725 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -499,11 +499,12 @@ else 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. @@ -629,8 +630,9 @@ 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. -cache_probe="$(in_sbx 'if [ -d "$HOME/.npm" ]; then owner=$(id -u) || exit; result=$(find "$HOME/.npm" -not -user "$owner" -print -quit 2>/dev/null) || exit; printf "%s\n" "$result"; fi; printf CACHE_PROBE_DONE')" +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; result=$(find "$HOME/.npm" -not -user "$owner" -print -quit 2>/dev/null) || exit; 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 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 3c91a4d0..7126b07a 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -164,10 +164,23 @@ test("cache ownership probe treats missing completion as unverified", () => { 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_PROBE_DONE", "PASS"], ["/foreign/file\nCACHE_PROBE_DONE", "FAIL"]]) { + 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("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); + } +}); From 35efa98b74caa55a0161ea6505bcaebec33c4596 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Fri, 30 Oct 2026 00:04:57 -0400 Subject: [PATCH 20/21] fix(paseo): require complete guest probe on published port Reject port overrides inconsistent with the kit, require successful guest curl completion for a bundle baseline, and fail closed on cache traversal diagnostics. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#548 --- .../isolation/acq-kits/paseo/scripts/verify | 9 +++++++-- .../acq-kits/paseo/tests/paseo-verify.test.mjs | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index a43cc725..8c404658 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -82,6 +82,10 @@ if [[ ! "$PASEO_PORT" =~ ^[0-9]{1,5}$ ]] || 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. @@ -487,7 +491,8 @@ else 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)" @@ -630,7 +635,7 @@ 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. -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; result=$(find "$HOME/.npm" -not -user "$owner" -print -quit 2>/dev/null) || exit; printf "%s\n" "$result"; printf CACHE_PROBE_DONE')" +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"; printf "%s\n" "$result"; printf CACHE_PROBE_DONE')" case "$cache_probe" in CACHE_ABSENT) skip "npm cache directory absent; ownership unverified" ;; *CACHE_PROBE_DONE) 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 7126b07a..58881701 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -148,7 +148,7 @@ test("port override rejects guest shell source and out-of-range ports", () => { 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"], ["00080", 0, "80"], ["0", 1], ["65536", 1], ["6767;id", 1], ["1$(id)", 1], ["-1", 1]]) { + 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, }); @@ -184,3 +184,16 @@ test("host reachability is not credited without an in-guest identity baseline", 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}`); + } +}); From 475aa0a5244c62df268e3b3de24f17a02a8ad2dd Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Fri, 30 Oct 2026 17:10:24 -0400 Subject: [PATCH 21/21] fix(paseo): avoid empty cache probe separator Emit a cache-owner path separator only when find returns a foreign-owned entry. Run the actual probe for an absent cache and for empty and populated agent-owned caches. Co-authored-by: OpenCode Refs: GSA-TTS/agentic-coding-patterns#559 --- .../isolation/acq-kits/paseo/scripts/verify | 2 +- .../paseo/tests/paseo-verify.test.mjs | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index 8c404658..50c98179 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -635,7 +635,7 @@ 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. -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"; printf "%s\n" "$result"; printf CACHE_PROBE_DONE')" +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) 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 58881701..2d754ae2 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-verify.test.mjs @@ -172,6 +172,29 @@ test("cache ownership probe treats missing completion as unverified", () => { } }); +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);