mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
052c971785
* feat(plugin): consent-gated push monitor + tri-state arm/disarm + envelope (PLAN-2613 S3, TASK-2618) The plugin layer of the push-consent gate. S2 built the CLI arm/disarm/status verbs and the arm-state file; S3 makes the monitor existence itself the gate (D1) and adds the tri-state, the envelope, and the connect ritual. - Tri-state arm-state file: a session can be explicitly ARMED, explicitly DISARMED, or absent. `pad session disarm` now writes a session-scoped OFF marker (not a file removal), so a within-session disconnect wins even in an auto_arm=true repo — the disconnect verb must not be a lie there. The marker dies with the session (same liveness), so across sessions auto_arm remains the standing contract. ResolveAnnouncedArmed folds the tri-state over auto_arm; the monitor announces its result. - Gated monitors (monitors.json): the single always-on monitor is replaced by two — an `always` auto-arm monitor and an `on-skill-invoke:connect` manual monitor — both running scripts/pad-monitor.sh. The wrapper gates on a new hidden `pad session should-arm`, dedupes concurrent monitors with a liveness-aware per-session lockfile, and carries the reconnect loop so an in-session disarm stops the stream on its next reconnect. No consent → the monitor exits → nothing listening. - D5 envelope: a push notification carries the verbatim direction-with-authority framing (confirm in-session before anything destructive/irreversible); item- change kinds stay a light informational label. - /pad:connect + /pad:disconnect skills; /pad:status gains a one-line connection header from `pad session status`. /pad:connect runs the workspace's on-session-start playbooks on the first connect only (D8), tracked by a Booted flag carried forward across arm/disarm. plugin 0.2.1 → 0.3.0. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(plugin): address Codex R1 on S3 (disarm stops active stream, fail-closed local state) - HIGH-1: a within-session disarm now stops an ACTIVE stream, not just the next reconnect. The monitor re-checks consent every 2s while streaming and cancels the connection when it flips to not-armed, then exits (D1's whole- stream-behind-consent gate at the top of the loop), so the plugin wrapper keeps it dead. Fixes /pad:disconnect being a lie for an idle SSE that might never naturally reconnect. - HIGH-2: a corrupt/unreadable local arm-state file now fails CLOSED (LocalArmError -> not armed) instead of falling through to auto_arm, so a corrupted disarm marker can't silently re-arm an auto_arm repo. It is not reaped (reaping would re-arm on the next read); it is session-keyed and a re-arm overwrites it. - Shell wrapper: an empty (mid-startup) lock pid is treated as live so two monitors can't both steal the lock; INT/TERM now exit (a trap otherwise resumes the loop and reconnects without a lock). - Docs: plugin/skills/pad describes the new push-envelope line format; connect/status skills distinguish "consent set (armed)" from the server's observed connection counts rather than claiming "Connected". Bounded/safe-direction residuals documented in code: the reap TOCTOU and the Booted carry-forward race (both fail-closed / benign), and lock pid-reuse (dedupe only, fails toward not-streaming). Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(plugin): address Codex R2 on S3 (disarm-watcher timing, semantic corruption fail-closed) - HIGH-1: the disarm-watcher now starts BEFORE the connection is opened, so a disarm during connection/header negotiation cancels the request too (the request is built on streamCtx). streamWatchEvents also re-checks consent before delivering each notification and stops the stream if it was withdrawn, so no push is printed after a disarm even within the poll window. - HIGH-2: a syntactically-valid but semantically-garbage arm-state file (e.g. {} or {"pid":1}) now fails CLOSED via a well-formedness check (StartedAt + PID must be present, as our writer always stamps them) before liveness or reaping — so it can't be judged owner-dead, reaped, and re-armed through auto_arm, nor mistaken for a live headless arm naming init. - LOW: the cleanup trap uses condition 0 (portable) rather than the EXIT name. The disconnect skill note reflects the ~2s active-stream drop. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(plugin): /pad:disconnect always disarms, never gated on a linked workspace (Codex R3) Consent is session-scoped (keyed by the messaging socket, not the workspace), so a session that connected in one repo must be able to disconnect from anywhere — including a directory with no .pad.toml. The old precondition let a session move to an unlinked directory, "disconnect", and keep receiving pushes. Verified: `pad session disarm` from an unlinked cwd disarms the socket-keyed session state; should-arm then reports not-armed back in the original repo. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(cli): enforce the Armed != Disarmed writer invariant in arm-state validation (Codex R4) armStateWellFormed checked only StartedAt + PID, so a well-stamped file that violated the writer invariant — both armed and disarmed false (or both true) — passed validation and, since SessionArmState only branches on Disarmed, resolved to LocalArmOn and armed. The writer always sets exactly one of the two; require it, so a neither/both file fails closed (LocalArmError). Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// armStateTestEnv points HOME (and thus ~/.pad/sessions) at a temp dir and
|
|
// sets the messaging socket env to socket ("" for the headless case),
|
|
// then chdirs to a fresh repo dir so the cwd fallback key is stable.
|
|
// Returns the repo dir.
|
|
func armStateTestEnv(t *testing.T, socket string) string {
|
|
t.Helper()
|
|
t.Setenv("HOME", t.TempDir())
|
|
t.Setenv("CLAUDE_CODE_MESSAGING_SOCKET", socket)
|
|
repo := t.TempDir()
|
|
chdir(t, repo)
|
|
return repo
|
|
}
|
|
|
|
func TestArmStateKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sessKey, headless := armStateKey("/run/user/1000/msg.sock", "/home/x/repo")
|
|
if headless {
|
|
t.Fatal("socket present must NOT be headless")
|
|
}
|
|
if !strings.HasPrefix(sessKey, "sess-") {
|
|
t.Fatalf("socket key = %q, want sess- prefix", sessKey)
|
|
}
|
|
|
|
repoKey, headless := armStateKey("", "/home/x/repo")
|
|
if !headless {
|
|
t.Fatal("no socket must be headless (cwd fallback)")
|
|
}
|
|
if !strings.HasPrefix(repoKey, "repo-") {
|
|
t.Fatalf("cwd key = %q, want repo- prefix", repoKey)
|
|
}
|
|
|
|
// A socket wins over cwd, and different sockets key differently while
|
|
// the same socket is stable (so arm and monitor in one session agree).
|
|
if sessKey == repoKey {
|
|
t.Fatal("socket and cwd keys must differ")
|
|
}
|
|
other, _ := armStateKey("/run/user/1000/other.sock", "/home/x/repo")
|
|
if other == sessKey {
|
|
t.Fatal("different sockets must produce different keys")
|
|
}
|
|
again, _ := armStateKey("/run/user/1000/msg.sock", "/different/cwd")
|
|
if again != sessKey {
|
|
t.Fatal("same socket must produce the same key regardless of cwd")
|
|
}
|
|
}
|
|
|
|
// TestArmState_SocketLivenessRoundTrip is the core happy path AND the
|
|
// non-negotiable liveness rule (constraint 2): a socket-keyed session is
|
|
// armed while its socket exists and DISARMED the instant the socket
|
|
// vanishes (the Claude Code session ended), with the stale file reaped so
|
|
// it can never arm a future monitor.
|
|
func TestArmState_SocketLivenessRoundTrip(t *testing.T) {
|
|
socketFile := filepath.Join(t.TempDir(), "msg.sock")
|
|
if err := os.WriteFile(socketFile, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
armStateTestEnv(t, socketFile)
|
|
|
|
path, err := WriteArmState()
|
|
if err != nil {
|
|
t.Fatalf("WriteArmState: %v", err)
|
|
}
|
|
if !SessionArmedLocally() {
|
|
t.Fatal("armed session with a live socket must read as armed")
|
|
}
|
|
|
|
// Socket vanishes → owner is dead → disarmed, and the file is reaped.
|
|
if err := os.Remove(socketFile); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if SessionArmedLocally() {
|
|
t.Fatal("a session whose socket vanished must NOT read as armed (consent-grandfathering)")
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("stale arm-state file must be reaped by the reader; stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestArmState_DisarmRemovesAndIsIdempotent covers constraint 3: disarm
|
|
// removes the file and reports whether one was there; a second disarm is a
|
|
// success, not an error.
|
|
func TestArmState_DisarmRemovesAndIsIdempotent(t *testing.T) {
|
|
socketFile := filepath.Join(t.TempDir(), "msg.sock")
|
|
if err := os.WriteFile(socketFile, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
armStateTestEnv(t, socketFile)
|
|
|
|
if _, err := WriteArmState(); err != nil {
|
|
t.Fatalf("WriteArmState: %v", err)
|
|
}
|
|
removed, _, err := RemoveArmState()
|
|
if err != nil {
|
|
t.Fatalf("RemoveArmState: %v", err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("first disarm should report a file was removed")
|
|
}
|
|
if SessionArmedLocally() {
|
|
t.Fatal("session must not read as armed after disarm")
|
|
}
|
|
|
|
removed, _, err = RemoveArmState()
|
|
if err != nil {
|
|
t.Fatalf("idempotent disarm must not error: %v", err)
|
|
}
|
|
if removed {
|
|
t.Fatal("second disarm should report nothing was removed")
|
|
}
|
|
}
|
|
|
|
// TestArmState_HeadlessLivePid: the cwd-fallback path with no socket reads
|
|
// as armed while its owner pid (this process) is alive.
|
|
func TestArmState_HeadlessLivePid(t *testing.T) {
|
|
armStateTestEnv(t, "") // no socket → headless, cwd-keyed, pid liveness
|
|
|
|
if _, err := WriteArmState(); err != nil {
|
|
t.Fatalf("WriteArmState: %v", err)
|
|
}
|
|
if !SessionArmedLocally() {
|
|
t.Fatal("headless arm owned by this live process must read as armed")
|
|
}
|
|
}
|
|
|
|
// TestArmState_HeadlessDeadPidReaped: the liveness rule for the headless
|
|
// fallback — a file whose owner pid is gone reads as DISARMED and is
|
|
// reaped. Uses a genuinely-exited process's pid rather than a guessed
|
|
// number, so the test doesn't depend on a pid being coincidentally free.
|
|
func TestArmState_HeadlessDeadPidReaped(t *testing.T) {
|
|
repo := armStateTestEnv(t, "")
|
|
|
|
deadPID := exitedProcessPID(t)
|
|
|
|
// Write a headless arm-state file by hand with the dead owner pid.
|
|
path, err := armStatePath("", repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st := ArmState{Armed: true, PID: deadPID, Cwd: repo, StartedAt: "2026-08-18T00:00:00Z"}
|
|
data, _ := json.MarshalIndent(st, "", " ")
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if SessionArmedLocally() {
|
|
t.Fatal("headless arm owned by a dead pid must NOT read as armed")
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("dead-owner headless file must be reaped; stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestArmState_MalformedFailsClosedNotAutoArm: an unparseable arm-state
|
|
// file reads as LocalArmError → NOT armed, and does NOT fall back to
|
|
// auto_arm even in an auto_arm=true repo (Codex R1 S3 HIGH-2). It is NOT
|
|
// reaped — reaping would make the next read "absent" and re-arm via
|
|
// auto_arm, re-arming despite a possible-but-unreadable disarm.
|
|
func TestArmState_MalformedFailsClosedNotAutoArm(t *testing.T) {
|
|
// auto_arm=true repo, so the fail-closed-vs-auto_arm distinction bites.
|
|
socketFile := triStateEnv(t)
|
|
path, err := armStatePath(socketFile, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte("{not json"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := SessionArmState(); got != LocalArmError {
|
|
t.Fatalf("SessionArmState on corrupt file = %v, want LocalArmError", got)
|
|
}
|
|
if ResolveAnnouncedArmed() {
|
|
t.Fatal("a corrupt arm-state file must fail closed — NOT armed, and NOT via auto_arm")
|
|
}
|
|
if SessionArmedLocally() {
|
|
t.Fatal("corrupt file must not read as locally armed")
|
|
}
|
|
// Not reaped: the file remains so the state stays consistently closed.
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("corrupt file must NOT be reaped (would re-arm via auto_arm next read): %v", err)
|
|
}
|
|
}
|
|
|
|
// TestArmState_SocketIdentityMismatchRejected is the Codex R1 HIGH-2 / R2
|
|
// finding-2 regression: a stale arm file must not arm a session that
|
|
// merely reuses the socket PATH. On unix the file records the socket's
|
|
// inode; a different node at the same path (a rebind, or a lingering stale
|
|
// node reused as-is) has a different inode and must read as disarmed. The
|
|
// tamper is deterministic — it forces the recorded identity to not match
|
|
// the live socket — where a remove+recreate could coincidentally reuse the
|
|
// inode.
|
|
func TestArmState_SocketIdentityMismatchRejected(t *testing.T) {
|
|
socketFile := filepath.Join(t.TempDir(), "msg.sock")
|
|
if err := os.WriteFile(socketFile, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
armStateTestEnv(t, socketFile)
|
|
|
|
path, err := WriteArmState()
|
|
if err != nil {
|
|
t.Fatalf("WriteArmState: %v", err)
|
|
}
|
|
if !SessionArmedLocally() {
|
|
t.Fatal("freshly armed session must read as armed")
|
|
}
|
|
|
|
// Read the recorded state and corrupt ONLY the inode, leaving the
|
|
// mtime correct, so this isolates the inode-identity check: mtime-only
|
|
// logic would wrongly still match. Skip where inode identity isn't
|
|
// recorded (non-unix), which uses the mtime fallback covered separately.
|
|
st, _, err := readArmState()
|
|
if err != nil || st == nil {
|
|
t.Fatalf("read state: %v", err)
|
|
}
|
|
if st.SocketIno == 0 {
|
|
t.Skip("no inode identity on this platform; mtime fallback covered separately")
|
|
}
|
|
st.SocketIno++
|
|
data, _ := json.MarshalIndent(st, "", " ")
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if SessionArmedLocally() {
|
|
t.Fatal("a socket-identity mismatch (reused path) must NOT arm the stale file")
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("stale-identity file must be reaped; stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestArmState_SocketMtimeFallbackMismatch exercises the non-unix fallback
|
|
// branch (no inode identity available): with SocketIno=0 the reader
|
|
// compares the socket's mtime, and a mismatch must read as disarmed.
|
|
func TestArmState_SocketMtimeFallbackMismatch(t *testing.T) {
|
|
socketFile := filepath.Join(t.TempDir(), "msg.sock")
|
|
if err := os.WriteFile(socketFile, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
armStateTestEnv(t, socketFile)
|
|
path, err := armStatePath(socketFile, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A state with no inode identity (SocketIno=0) and a wrong mtime —
|
|
// the shape a non-unix arm would produce for a reused path.
|
|
st := ArmState{
|
|
Armed: true,
|
|
PID: os.Getpid(),
|
|
Socket: socketFile,
|
|
SocketMtimeUnixNano: 1, // will not match the real socket
|
|
Cwd: "",
|
|
StartedAt: "2026-08-18T00:00:00Z",
|
|
}
|
|
data, _ := json.MarshalIndent(st, "", " ")
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if SessionArmedLocally() {
|
|
t.Fatal("mtime-fallback mismatch must NOT arm the stale file")
|
|
}
|
|
}
|
|
|
|
// TestReapArmFile_NonDestructive: reap must NOT delete a file that was
|
|
// re-armed with a live owner between the read and the reap (Codex R1
|
|
// MED-1). Simulated by pointing reap at a path that currently holds a
|
|
// live arm.
|
|
func TestReapArmFile_NonDestructive(t *testing.T) {
|
|
socketFile := filepath.Join(t.TempDir(), "msg.sock")
|
|
if err := os.WriteFile(socketFile, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
armStateTestEnv(t, socketFile)
|
|
|
|
path, err := WriteArmState()
|
|
if err != nil {
|
|
t.Fatalf("WriteArmState: %v", err)
|
|
}
|
|
// The file at `path` is live. A reap attempt must leave it alone
|
|
// because the re-read shows a live owner.
|
|
reapArmFile(path)
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("non-destructive reap must not delete a live arm file: %v", err)
|
|
}
|
|
if !SessionArmedLocally() {
|
|
t.Fatal("session must still be armed after a no-op reap")
|
|
}
|
|
}
|
|
|
|
// exitedProcessPID starts and reaps a trivial process, returning its pid,
|
|
// which is then dead. Skips on platforms without a shell.
|
|
func exitedProcessPID(t *testing.T) int {
|
|
t.Helper()
|
|
sh, err := exec.LookPath("sh")
|
|
if err != nil {
|
|
t.Skip("no shell to spawn a dead process from")
|
|
}
|
|
cmd := exec.Command(sh, "-c", "exit 0")
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start throwaway process: %v", err)
|
|
}
|
|
pid := cmd.Process.Pid
|
|
_ = cmd.Wait() // reap — pid is now dead
|
|
return pid
|
|
}
|