From fd5fef82ba89b6ec9e8bb1848fb86be12b92034a Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 8 Oct 2026 07:20:00 +0000 Subject: [PATCH 1/2] fix(paseo-kit): reconcile pre-boot daemon lock Reconcile a live Paseo supervisor whose lock predates the current kernel boot during startup, then verify default local CLI discovery. Co-authored-by: OpenCode Agent --- .../docs/decisions/paseo-0.9-daemon-run.md | 8 +- .../acq-kits/paseo/files/home/paseo-start.sh | 137 ++++++++++++++++++ .../isolation/acq-kits/paseo/scripts/verify | 13 ++ .../tests/paseo-start-reconcile.test.mjs | 79 ++++++++++ 4 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs diff --git a/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-0.9-daemon-run.md b/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-0.9-daemon-run.md index a1d3d0ef..8636f556 100644 --- a/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-0.9-daemon-run.md +++ b/integrations/isolation/acq-kits/paseo/docs/decisions/paseo-0.9-daemon-run.md @@ -49,7 +49,13 @@ CLI restart cannot run. - The kit no longer passes removed Paseo 0.9 flags. - The daemon still comes up automatically on sandbox start and remains supervised. - `paseo daemon status` and config tooling report the same listen/web UI/relay - settings that the sandbox uses at runtime. + settings that the sandbox uses at runtime. On a restored or migrated sandbox, + the lock can retain a `startedAt` timestamp from before the current kernel + boot even while the daemon process tree remains healthy. During startup only, + the kit detects that condition, verifies the lock PID is Paseo's own supervisor, + and performs a bounded graceful stop so the outer supervisor can write a fresh + lock. Ambiguous, malformed, post-boot, or unreconciled locks are left intact; + the startup script logs the reason and does not start a second daemon. - The `0.0.0.0:6767` bind remains explicit, preserving msb create-time port reachability. - Future Paseo version bumps must verify both the version pin and daemon command diff --git a/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh b/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh index 1d5af04c..4514fb7d 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh +++ b/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh @@ -38,6 +38,138 @@ set -eu +# Reconcile a lock left behind by a sandbox restore/migration. This runs only in +# this startup process, never from the daemon's respawn loop. A lock is eligible +# only when its timestamp predates the current kernel boot and its PID is the +# Paseo-owned supervisor-entrypoint, not merely any live process. +reconcile_preboot_lock() { + _lock="${PASEO_HOME_DIR:-${PASEO_HOME:-$HOME/.paseo}}/paseo.pid" + _log="${STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/paseo}/paseo-start.log" + _proc_root=/proc + _btime="" + _pid="" + _started="" + _started_epoch="" + + mkdir -p "$(dirname "$_log")" 2>/dev/null || true + + [ -f "$_lock" ] || return 0 + _btime="$(awk '$1 == "btime" { print $2; exit }' "$_proc_root/stat" 2>/dev/null || true)" + if [ "${PASEO_START_RECONCILE_TEST:-}" = 1 ]; then + _proc_root="${PASEO_PROC_ROOT:-$_proc_root}" + _btime="${PASEO_KERNEL_BOOT_TIME:-$_btime}" + fi + if ! case "$_btime" in ''|*[!0-9]*) false ;; *) true ;; esac; then + echo "paseo: pre-boot lock check: kernel boot time unavailable; leaving $_lock untouched" >>"$_log" 2>/dev/null || true + return 1 + fi + + # Node parses the lock's JSON and normalizes ISO or numeric startedAt values. + # It emits one tab-separated record and rejects extra/malformed fields. + _lock_record="$(node -e ' + const fs = require("fs"); + try { + const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const pid = Number(value.pid); + const started = value.startedAt; + const epoch = typeof started === "number" ? started : Date.parse(String(started)) / 1000; + if (!Number.isInteger(pid) || pid <= 1 || !Number.isFinite(epoch)) process.exit(2); + process.stdout.write(`${pid}\t${Math.floor(epoch)}\n`); + } catch { process.exit(2); } + ' "$_lock" 2>/dev/null || true)" + _tab="$(printf '\t')" + _pid="${_lock_record%%$_tab*}" + _started="${_lock_record#*$_tab}" + case "$_pid" in + ''|*[!0-9]*) + echo "paseo: pre-boot lock check: malformed or ambiguous lock $_lock; leaving it untouched" >>"$_log" 2>/dev/null || true + return 1 ;; + esac + case "$_started" in + ''|*[!0-9]*) + echo "paseo: pre-boot lock check: malformed or ambiguous lock $_lock; leaving it untouched" >>"$_log" 2>/dev/null || true + return 1 ;; + esac + _started_epoch="$_started" + if [ "$_started_epoch" -ge "$_btime" ] 2>/dev/null; then + return 0 + fi + + _cmdline="$(tr '\0' ' ' <"$_proc_root/$_pid/cmdline" 2>/dev/null || true)" + _start_ticks="$(awk '{ print $22 }' "$_proc_root/$_pid/stat" 2>/dev/null || true)" + _uid="$(awk '/^Uid:/ { print $2; exit }' "$_proc_root/$_pid/status" 2>/dev/null || true)" + _self_uid="$(id -u)" + case "$_cmdline" in + *supervisor-entrypoint.js*) + case "$_start_ticks" in + ''|*[!0-9]*) + echo "paseo: pre-boot lock check: PID $_pid has unreadable process identity; leaving $_lock untouched" >>"$_log" 2>/dev/null || true + return 1 ;; + esac + if [ "$_uid" != "$_self_uid" ]; then + echo "paseo: pre-boot lock check: PID $_pid has unexpected owner; leaving $_lock untouched" >>"$_log" 2>/dev/null || true + return 1 + fi ;; + '') + echo "paseo: pre-boot lock check: PID $_pid is not live; leaving $_lock for Paseo to reclaim" >>"$_log" 2>/dev/null || true + return 0 ;; + *) + echo "paseo: pre-boot lock check: PID $_pid is live but is not the Paseo supervisor; leaving $_lock untouched" >>"$_log" 2>/dev/null || true + return 1 ;; + esac + + _tree_pids="$_pid" + _pending="$_pid" + while [ -n "$_pending" ]; do + _children="" + for _parent in $_pending; do + _children="$_children $(pgrep -P "$_parent" 2>/dev/null || true)" + done + _pending="" + for _child in $_children; do + case " $_tree_pids " in *" $_child "*) ;; *) _tree_pids="$_tree_pids $_child"; _pending="$_pending $_child" ;; esac + done + done + + echo "paseo: pre-boot lock check: stopping restored Paseo supervisor PID $_pid" >>"$_log" 2>/dev/null || true + _check_start_ticks="$(awk '{ print $22 }' "$_proc_root/$_pid/stat" 2>/dev/null || true)" + _check_cmdline="$(tr '\0' ' ' <"$_proc_root/$_pid/cmdline" 2>/dev/null || true)" + [ "$_check_start_ticks" = "$_start_ticks" ] && [ "$_check_cmdline" = "$_cmdline" ] || { + echo "paseo: pre-boot lock check: supervisor identity changed before stop; leaving $_lock untouched" >>"$_log" 2>/dev/null || true + return 1 + } + kill -TERM "$_pid" 2>/dev/null || { + echo "paseo: pre-boot lock check: could not stop PID $_pid; leaving $_lock untouched" >>"$_log" 2>/dev/null || true + return 1 + } + _deadline=$(( $(date +%s) + 20 )) + while :; do + _tree_live="" + for _tree_pid in $_tree_pids; do + [ -e "$_proc_root/$_tree_pid" ] && _tree_live=yes + done + [ -z "$_tree_live" ] && [ ! -f "$_lock" ] && { + echo "paseo: pre-boot lock check: restored supervisor stopped and lock released" >>"$_log" 2>/dev/null || true + return 0 + } + if [ "$(date +%s)" -ge "$_deadline" ]; then + echo "paseo: pre-boot lock check: timeout waiting for PID $_pid and $_lock; leaving lock untouched" >>"$_log" 2>/dev/null || true + return 1 + fi + sleep 1 + done +} + +# Test-only entry point used by the kit regression suite. It is deliberately +# checked before installation and daemon startup, so it cannot affect a sandbox. +if [ "${PASEO_START_RECONCILE_TEST:-}" = 1 ]; then + PASEO_HOME_DIR="${PASEO_HOME:-$HOME/.paseo}" + STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/paseo" + mkdir -p "$STATE_DIR" + reconcile_preboot_lock + exit $? +fi + # Daemon bind. Keep in sync with the kit spec's publishedPorts guest port (6767) # and PASEO_LISTEN env. We derive the port from PASEO_LISTEN so a single override # moves both the bind and the health probe. The default is 0.0.0.0 (all guest @@ -197,6 +329,11 @@ if [ -z "$config_ok" ]; then exit 0 fi +if ! reconcile_preboot_lock; then + echo "paseo: pre-boot lock reconciliation did not complete; not starting a second daemon" >&2 + exit 0 +fi + # --- Supervise the Paseo daemon (idempotent). -------------------------------- # The daemon runs UNSECURED (no PASEO_PASSWORD) — the sandbox is the security # boundary, and the published host port is loopback only. `daemon run` is Paseo diff --git a/integrations/isolation/acq-kits/paseo/scripts/verify b/integrations/isolation/acq-kits/paseo/scripts/verify index e4859af4..9277fd27 100755 --- a/integrations/isolation/acq-kits/paseo/scripts/verify +++ b/integrations/isolation/acq-kits/paseo/scripts/verify @@ -395,6 +395,19 @@ else in_sbx 'tail -n 20 ~/.local/state/paseo/paseo-daemon.log' | sed 's/^/ | /' fi +info "5a. Default local CLI discovery uses the running daemon" +local_status="$(in_sbx 'paseo daemon status --home "${PASEO_HOME:-$HOME/.paseo}" --json 2>/dev/null')" +if printf '%s' "$local_status" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try { const v=JSON.parse(s); const running=v.status === "running" || v.state === "running" || v.localDaemon?.status === "running" || v.localDaemon?.state === "running"; process.exit(running ? 0 : 1); } catch { process.exit(1); }})'; then + ok "local daemon status reports running without an explicit host" +else + bad "local daemon status did not report running: ${local_status:-no output}" +fi +if [ "$(in_sbx 'paseo ls --home "${PASEO_HOME:-$HOME/.paseo}" --json >/dev/null 2>&1 && echo yes')" = "yes" ]; then + ok "paseo ls succeeds through default local discovery without --host" +else + bad "paseo ls failed through default local discovery without --host" +fi + config_assert="$(in_sbx "node /home/agent/paseo-assert-daemon-config.mjs --paseo-home \"\${PASEO_HOME:-\$HOME/.paseo}\" --listen '0.0.0.0:$PASEO_PORT' 2>&1 && echo ok")" if [ "$config_assert" = "ok" ]; then ok "config.json persists required daemon settings (listen, web UI, relay disabled)" diff --git a/integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs b/integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs new file mode 100644 index 00000000..bed8e617 --- /dev/null +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +const startup = join(import.meta.dirname, "../files/home/paseo-start.sh"); + +function runReconcile({ startedAt, commandLine, bootTime = 2_000 }) { + const root = mkdtempSync(join(tmpdir(), "paseo-reconcile-")); + const home = join(root, "home"); + const proc = join(root, "proc"); + const state = join(root, "state"); + mkdirSync(home); + mkdirSync(proc); + writeFileSync(join(proc, "stat"), `btime ${bootTime}\n`); + + const supervisor = spawn("sh", ["-c", `trap 'rm -f "$1"; rm -rf "$FAKE_PROC_ROOT/$$"; exit 0' TERM INT; while :; do sleep 1; done`, "sh", join(home, "paseo.pid")], { + env: { ...process.env, FAKE_PROC_ROOT: proc }, + stdio: "ignore", + }); + mkdirSync(join(proc, String(supervisor.pid))); + writeFileSync(join(proc, String(supervisor.pid), "cmdline"), `${commandLine}\0`); + writeFileSync(join(proc, String(supervisor.pid), "stat"), `1 (sh) S 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 12345\n`); + writeFileSync(join(proc, String(supervisor.pid), "status"), `Name:\tsh\nUid:\t${process.getuid()}\t${process.getuid()}\t${process.getuid()}\t${process.getuid()}\n`); + writeFileSync(join(home, "paseo.pid"), JSON.stringify({ pid: supervisor.pid, startedAt })); + + let result; + try { + result = execFileSync("sh", [startup], { + env: { + ...process.env, + HOME: home, + PASEO_HOME: home, + XDG_STATE_HOME: state, + PASEO_PROC_ROOT: proc, + PASEO_KERNEL_BOOT_TIME: String(bootTime), + PASEO_START_RECONCILE_TEST: "1", + }, + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + result = `${error.stdout ?? ""}${error.stderr ?? ""}`; + } + supervisor.kill("SIGKILL"); + const logPath = join(state, "paseo", "paseo-start.log"); + return { home, log: existsSync(logPath) ? readFileSync(logPath, "utf8") : "", result }; +} + +test("reconciles a live supervisor with a pre-boot lock", () => { + const { home, log } = runReconcile({ + startedAt: "1970-01-01T00:00:01Z", + commandLine: "node supervisor-entrypoint.js", + }); + assert.equal(existsSync(join(home, "paseo.pid")), false, `the supervisor trap releases the lock (${log})`); + assert.match(log, /restored supervisor stopped and lock released/); +}); + +test("does not interrupt a post-boot supervisor lock", () => { + const { home, log } = runReconcile({ + startedAt: "1970-01-01T00:33:20Z", + commandLine: "node supervisor-entrypoint.js", + bootTime: 2_000, + }); + assert.match(readFileSync(join(home, "paseo.pid"), "utf8"), /startedAt/); + assert.doesNotMatch(log, /stopping restored/); +}); + +test("leaves malformed and ambiguously owned locks untouched", () => { + const malformed = runReconcile({ startedAt: "not-a-time", commandLine: "node supervisor-entrypoint.js" }); + assert.match(readFileSync(join(malformed.home, "paseo.pid"), "utf8"), /not-a-time/); + assert.match(`${malformed.log}${malformed.result}`, /malformed or ambiguous/); + + const ambiguous = runReconcile({ startedAt: "1970-01-01T00:00:01Z", commandLine: "node unrelated-process.js" }); + assert.match(readFileSync(join(ambiguous.home, "paseo.pid"), "utf8"), /startedAt/); + assert.match(ambiguous.log, /not the Paseo supervisor/); +}); From bdb0c5a357c9725f4f52672579d2d49ea8f6748e Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 14 Oct 2026 03:11:43 +0000 Subject: [PATCH 2/2] fix(paseo-kit): validate daemon lock metadata Reject implausible lock timestamps and non-numeric PIDs before reconciliation. Co-authored-by: OpenCode Agent --- .../acq-kits/paseo/files/home/paseo-start.sh | 13 +++++-- .../tests/paseo-start-reconcile.test.mjs | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh b/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh index 4514fb7d..e8a25a7c 100755 --- a/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh +++ b/integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh @@ -65,15 +65,22 @@ reconcile_preboot_lock() { fi # Node parses the lock's JSON and normalizes ISO or numeric startedAt values. - # It emits one tab-separated record and rejects extra/malformed fields. + # It emits one tab-separated record and rejects malformed or implausible fields. _lock_record="$(node -e ' const fs = require("fs"); try { const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); - const pid = Number(value.pid); + const pid = value.pid; const started = value.startedAt; const epoch = typeof started === "number" ? started : Date.parse(String(started)) / 1000; - if (!Number.isInteger(pid) || pid <= 1 || !Number.isFinite(epoch)) process.exit(2); + if ( + typeof pid !== "number" || + !Number.isInteger(pid) || + pid <= 1 || + !Number.isFinite(epoch) || + epoch <= 0 || + epoch > Date.now() / 1000 + ) process.exit(2); process.stdout.write(`${pid}\t${Math.floor(epoch)}\n`); } catch { process.exit(2); } ' "$_lock" 2>/dev/null || true)" diff --git a/integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs b/integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs index bed8e617..8897fa73 100644 --- a/integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs +++ b/integrations/isolation/acq-kits/paseo/tests/paseo-start-reconcile.test.mjs @@ -77,3 +77,39 @@ test("leaves malformed and ambiguously owned locks untouched", () => { assert.match(readFileSync(join(ambiguous.home, "paseo.pid"), "utf8"), /startedAt/); assert.match(ambiguous.log, /not the Paseo supervisor/); }); + +test("leaves implausible timestamps and non-numeric PIDs untouched", () => { + for (const lock of [ + { pid: 1234, startedAt: -1 }, + { pid: 1234, startedAt: Math.floor(Date.now() / 1_000) + 60 }, + { pid: "1234", startedAt: "1970-01-01T00:00:01Z" }, + ]) { + const root = mkdtempSync(join(tmpdir(), "paseo-reconcile-invalid-lock-")); + const home = join(root, "home"); + const state = join(root, "state"); + mkdirSync(home); + writeFileSync(join(home, "paseo.pid"), JSON.stringify(lock)); + + let result; + try { + result = execFileSync("sh", [startup], { + env: { + ...process.env, + HOME: home, + PASEO_HOME: home, + XDG_STATE_HOME: state, + PASEO_START_RECONCILE_TEST: "1", + }, + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + result = `${error.stdout ?? ""}${error.stderr ?? ""}`; + } + + assert.deepEqual(JSON.parse(readFileSync(join(home, "paseo.pid"), "utf8")), lock); + const logPath = join(state, "paseo", "paseo-start.log"); + const log = existsSync(logPath) ? readFileSync(logPath, "utf8") : ""; + assert.match(`${log}${result}`, /malformed or ambiguous/); + } +});