Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions integrations/isolation/acq-kits/paseo/files/home/paseo-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,145 @@

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 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 = value.pid;
const started = value.startedAt;
const epoch = typeof started === "number" ? started : Date.parse(String(started)) / 1000;
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)"
_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
Expand Down Expand Up @@ -197,6 +336,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
Expand Down
13 changes: 13 additions & 0 deletions integrations/isolation/acq-kits/paseo/scripts/verify
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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/);
});

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/);
}
});
Loading