mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
e747a1610c
## Summary TASK-2767 (IDEA-2750 part 2, with part 3 riding along — the keying fix and the reaping are one mechanism). The local session registry (`~/.pad/sessions`) was keyed on the pid of the `pad session register` subprocess, which is dead before anyone reads the file. One session left a new file per call and its own pid appeared in none of them; the only live identifier was the harness pid a reader could parse out of the socket path's basename. In practice nothing wrote it (zero callers in `plugin/`, `skills/`, or hooks) and nothing read it. Now: - **One record per session, keyed on the harness session pid** — `$PAD_SESSION_PID` (harness-agnostic override), else `$CLAUDE_PID` (verified present in both the tool shell and a live plugin monitor's `/proc/<pid>/environ`), else the calling process. A set-but-invalid value is an error, not a silent fall-through. - **The record carries the agent name** the session's writes are attributed to (`ResolveAgentName`: `.pad.toml agent_name` → `$PAD_AGENT` → detected runtime; `--agent` overrides, `--agent ""` is anonymous), the harness session id, and the messaging socket's identity (inode/device/mtime — the same binding the arm-state file uses). - **One owner-identity type, one verdict.** `internal/cli/session_owner.go`: `SessionOwner` + tri-state `OwnerLiveness` (`alive` / `dead` / `unknown`). `armStateOwnerAlive` is now `OwnerLiveness(...) == alive` with its file contract preserved (socket identity else mtime; headless pid + start token; fail closed). The registry pruner takes the opposite posture on `unknown`: on Windows `pidAlive` reports dead for every pid, and a reaper built on that would delete every live session's record. - **Verbs:** `pad session register [--agent]` (writes/refreshes; prunes dead records), `pad session list [--agent] [--cwd] [--all] [--format json]` (liveness per row, newest first; dead hidden unless `--all`), `pad session prune [--older-than DUR]` (dead always; unknown only under an explicit bound; alive never). Nothing on MCP — host-local filesystem state. - **Who registers:** `plugin/scripts/pad-monitor.sh` runs `pad session register` on start, BEFORE the consent gate — presence is a fact, consent is a grant, and the record is local/0600/never on the wire. - **Legacy v1 files** list as `legacy` rows: owner = socket-basename pid (else registrar pid), liveness by pid only (v1 recorded no socket identity, and the socket-without-identity rule would have judged every legacy record dead while its session ran). A legacy row can say a session exists, never who it is. Lead rulings on the four open decisions, all as built: `agent`/`--agent` vocabulary; no server-presence merge in `list`; register from the monitor script before the gate; wire follow-on (agent name on the stream) filed separately as IDEA-2750 part 2b. One ordering change from the plan's section A: pid precedence is `PAD_SESSION_PID` > `CLAUDE_PID` > self (explicit override beats detection, mirroring `PAD_AGENT` over runtime detection); the plan listed `CLAUDE_PID` first. ## Behaviour changes for existing users of `~/.pad/sessions` / `pad session register` - Registry files are keyed on the **harness session pid** (`PAD_SESSION_PID` → `CLAUDE_PID` → self), not the `pad` command's pid; repeated registrations overwrite one record instead of accumulating. - `pad session register` records the agent name, harness session id and socket identity; stores the **real path** of the cwd; prints a different text line and a different JSON shape (the full `SessionRecord`); and **rejects** an invalid `PAD_SESSION_PID` / `CLAUDE_PID` instead of silently keying on itself. - Existing v1 files are read as `legacy` rows (owner = socket-basename pid, no agent name) and dead ones are pruned by the next register. - The plugin monitor now registers (and prunes) on every start, before the consent gate. - `armStateOwnerAlive` now delegates to the shared `OwnerLiveness`; the consent gate's observable behaviour is unchanged on every platform and key type (codex round 4 traced every caller; matrix M29 pins the socket-keyed mapping). https://claude.ai/code/session_016zc6oxBvpax6Z3iQMsAJno
711 lines
24 KiB
Go
711 lines
24 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// registryEnv gives a test a fresh HOME (so ~/.pad/sessions is empty) and
|
|
// a clean session environment, returning the sessions dir.
|
|
func registryEnv(t *testing.T) string {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
// Every verdict here is unknown on Windows (pids cannot be probed),
|
|
// and these tests assert the unix alive/dead verdicts. The
|
|
// registry's Windows behaviour is covered by its stated posture
|
|
// (unknown is never reaped without a bound), not by this suite.
|
|
t.Skip("registry tests assert unix liveness verdicts")
|
|
}
|
|
home := t.TempDir()
|
|
isolateHome(t, home)
|
|
clearSessionEnv(t)
|
|
return filepath.Join(home, ".pad", "sessions")
|
|
}
|
|
|
|
// livingChildPID starts a process that outlives the test body (killed in
|
|
// cleanup) and returns its pid: a LIVE pid that is not an ancestor.
|
|
func livingChildPID(t *testing.T) int {
|
|
t.Helper()
|
|
sh, err := exec.LookPath("sh")
|
|
if err != nil {
|
|
t.Skip("no shell to spawn a child from")
|
|
}
|
|
// exec so the shell is REPLACED by sleep: killing the pid then kills
|
|
// the sleeper itself, not a shell that would orphan it.
|
|
c := exec.Command(sh, "-c", "exec sleep 60")
|
|
if err := c.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = c.Process.Kill(); _ = c.Wait() })
|
|
return c.Process.Pid
|
|
}
|
|
|
|
func numericFiles(t *testing.T, dir string) []string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
if registryFileName.MatchString(e.Name()) {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func readRegistration(t *testing.T, path string) (SessionRegistration, map[string]any) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
var reg SessionRegistration
|
|
if err := json.Unmarshal(data, ®); err != nil {
|
|
t.Fatalf("parse %s: %v", path, err)
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return reg, raw
|
|
}
|
|
|
|
// TestRegisterSession_KeyedOnHarnessPid is the counterfactual against the
|
|
// v1 registry: the file is named after the HARNESS session pid (CLAUDE_PID
|
|
// here — the parent of this test process, a live pid that is not our own),
|
|
// not after os.Getpid(). Under v1 this assertion fails with a file named
|
|
// after the test process.
|
|
func TestRegisterSession_KeyedOnHarnessPid(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
parent := os.Getppid()
|
|
t.Setenv("CLAUDE_PID", itoa(parent))
|
|
t.Setenv("CLAUDE_CODE_SESSION_ID", "sess-abc")
|
|
|
|
rec, err := RegisterSession("/some/project", "wren")
|
|
if err != nil {
|
|
t.Fatalf("RegisterSession: %v", err)
|
|
}
|
|
wantPath := filepath.Join(dir, fmt.Sprintf("%d.json", parent))
|
|
if rec.Path != wantPath {
|
|
t.Fatalf("record path = %q, want %q (keyed on CLAUDE_PID, not os.Getpid())", rec.Path, wantPath)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, fmt.Sprintf("%d.json", os.Getpid()))); !os.IsNotExist(err) {
|
|
t.Fatalf("a file keyed on the registrar's own pid must not exist (v1 keying); stat err = %v", err)
|
|
}
|
|
|
|
reg, raw := readRegistration(t, rec.Path)
|
|
if reg.PID != parent || reg.PIDSource != "CLAUDE_PID" {
|
|
t.Fatalf("owner = %d/%q, want %d/CLAUDE_PID", reg.PID, reg.PIDSource, parent)
|
|
}
|
|
if reg.RegistrarPID != os.Getpid() {
|
|
t.Fatalf("registrar pid = %d, want %d (the v1 'pid' key keeps naming the writer)", reg.RegistrarPID, os.Getpid())
|
|
}
|
|
if raw["pid"] != float64(os.Getpid()) {
|
|
t.Fatalf("the v1 JSON key 'pid' must survive for compatibility, raw = %v", raw)
|
|
}
|
|
if reg.Agent != "wren" || reg.Cwd != "/some/project" || reg.SessionID != "sess-abc" || reg.RegisteredAt == "" {
|
|
t.Fatalf("record fields not round-tripped: %+v", reg)
|
|
}
|
|
if rec.SessionPID != parent || rec.Agent != "wren" || rec.Liveness != LivenessAlive || rec.Legacy {
|
|
t.Fatalf("returned record wrong: %+v", rec)
|
|
}
|
|
}
|
|
|
|
func TestRegisterSession_PadSessionPidOverridesClaudePid(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getpid()))
|
|
t.Setenv("PAD_SESSION_PID", itoa(os.Getppid()))
|
|
t.Setenv("PAD_SESSION_ID", "pad-id")
|
|
t.Setenv("CLAUDE_CODE_SESSION_ID", "claude-id")
|
|
rec, err := RegisterSession("/p", "x")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if filepath.Base(rec.Path) != fmt.Sprintf("%d.json", os.Getppid()) {
|
|
t.Fatalf("PAD_SESSION_PID must win: %s", rec.Path)
|
|
}
|
|
if rec.SessionPIDSource != "PAD_SESSION_PID" || rec.SessionID != "pad-id" {
|
|
t.Fatalf("override source/id not recorded: %+v", rec)
|
|
}
|
|
if files := numericFiles(t, dir); len(files) != 1 {
|
|
t.Fatalf("expected exactly one record, got %v", files)
|
|
}
|
|
}
|
|
|
|
// TestRegisterSession_RepeatedCallsOneFile is the day-57 shape as a
|
|
// counterfactual: ten registrations from one session produce ONE record,
|
|
// the newest. (v1 produced ten, keyed on ten dead command pids.)
|
|
func TestRegisterSession_RepeatedCallsOneFile(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
|
|
var last SessionRecord
|
|
for i := 0; i < 10; i++ {
|
|
rec, err := RegisterSession(fmt.Sprintf("/dir/%d", i), "rook")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last = rec
|
|
}
|
|
files := numericFiles(t, dir)
|
|
if len(files) != 1 {
|
|
t.Fatalf("ten registrations of one session must leave ONE file, got %v", files)
|
|
}
|
|
reg, _ := readRegistration(t, last.Path)
|
|
if reg.Cwd != "/dir/9" {
|
|
t.Fatalf("re-register must overwrite (refresh): cwd = %q", reg.Cwd)
|
|
}
|
|
}
|
|
|
|
func TestRegisterSession_FilePermissions(t *testing.T) {
|
|
registryEnv(t)
|
|
rec, err := RegisterSession("/some/dir", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := os.Stat(rec.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0600 {
|
|
t.Errorf("expected mode 0600, got %o", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestRegisterSession_AnonymousOmitsAgentKey(t *testing.T) {
|
|
registryEnv(t)
|
|
rec, err := RegisterSession("/some/dir", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, raw := readRegistration(t, rec.Path)
|
|
if _, ok := raw["agent"]; ok {
|
|
t.Fatalf("anonymous registration must omit the agent key, raw = %v", raw)
|
|
}
|
|
if _, ok := raw["messaging_socket_path"]; ok {
|
|
t.Fatalf("no socket → no messaging_socket_path key, raw = %v", raw)
|
|
}
|
|
}
|
|
|
|
func TestRegisterSession_PrunesDeadRecords(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
dead := exitedProcessPID(t)
|
|
writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: dead, PIDSource: "CLAUDE_PID"}, Agent: "ghost", Cwd: "/x", RegisteredAt: "2026-08-01T00:00:00Z"})
|
|
deadPath := filepath.Join(dir, fmt.Sprintf("%d.json", dead))
|
|
|
|
rec, err := RegisterSession("/y", "me")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(deadPath); !os.IsNotExist(err) {
|
|
t.Fatalf("register must prune the dead record; stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(rec.Path); err != nil {
|
|
t.Fatalf("own record must survive the prune: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeV2Record(t *testing.T, dir string, reg SessionRegistration) string {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reg.RegistrarPID == 0 {
|
|
reg.RegistrarPID = 1 // every real writer records one; well-formedness requires it
|
|
}
|
|
data, err := json.Marshal(reg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, fmt.Sprintf("%d.json", reg.PID))
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// writeV1Record writes a registry file exactly as the pre-TASK-2767 code
|
|
// did: keyed on the registrar pid, no session_pid, no agent, a bare
|
|
// socket path.
|
|
func writeV1Record(t *testing.T, dir string, registrarPID int, socket string) string {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw := map[string]any{"pid": registrarPID, "cwd": "/legacy", "registered_at": "2026-08-20T00:00:00Z"}
|
|
if socket != "" {
|
|
raw["messaging_socket_path"] = socket
|
|
}
|
|
data, _ := json.Marshal(raw)
|
|
path := filepath.Join(dir, fmt.Sprintf("%d.json", registrarPID))
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// TestListSessions_LegacyFiles: a v1 file is listed as legacy, owned by
|
|
// the pid in its socket basename (a LIVE harness pid here, so the row is
|
|
// alive) — never judged by the socket path itself, which recorded no
|
|
// identity. Without a parseable socket, the registrar pid is the owner,
|
|
// and a dead one reads dead.
|
|
func TestListSessions_LegacyFiles(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
deadA, deadB := exitedProcessPID(t), exitedProcessPID(t)
|
|
if deadA == deadB {
|
|
t.Skip("two throwaway processes got the same pid; cannot build two distinct legacy files")
|
|
}
|
|
// Socket path names a live pid (ours) but does NOT exist on disk — the
|
|
// socket must not be the liveness signal for a legacy row.
|
|
writeV1Record(t, dir, deadA, filepath.Join(t.TempDir(), fmt.Sprintf("%d.sock", os.Getpid())))
|
|
writeV1Record(t, dir, deadB, "")
|
|
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
byPath := map[string]SessionRecord{}
|
|
for _, r := range records {
|
|
byPath[filepath.Base(r.Path)] = r
|
|
}
|
|
a := byPath[fmt.Sprintf("%d.json", deadA)]
|
|
if !a.Legacy || a.SessionPID != os.Getpid() || a.SessionPIDSource != "legacy-socket" || a.Liveness != LivenessAlive || a.Agent != "" {
|
|
t.Fatalf("legacy row with socket-derived owner wrong: %+v", a)
|
|
}
|
|
b := byPath[fmt.Sprintf("%d.json", deadB)]
|
|
if !b.Legacy || b.SessionPID != deadB || b.SessionPIDSource != "legacy-registrar" || b.Liveness != LivenessDead {
|
|
t.Fatalf("legacy row with registrar owner wrong: %+v", b)
|
|
}
|
|
}
|
|
|
|
func TestListSessions_SkipsArmFilesListsMalformed(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Neighbours in the same directory that are NOT sessions.
|
|
for _, name := range []string{"arm-sess-abcdef.json", ".arm-123.tmp", "notes.txt"} {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(`{"armed":true,"pid":1}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
bad := filepath.Join(dir, "4242.json")
|
|
if err := os.WriteFile(bad, []byte("{not json"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Now().Add(-200 * time.Hour)
|
|
if err := os.Chtimes(bad, old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != 1 {
|
|
t.Fatalf("expected only the malformed numeric file to list, got %+v", records)
|
|
}
|
|
r := records[0]
|
|
if !r.Malformed || r.Liveness != LivenessUnknown || r.Path != bad {
|
|
t.Fatalf("malformed record wrong: %+v", r)
|
|
}
|
|
if ts, err := time.Parse(time.RFC3339, r.RegisteredAt); err != nil || time.Since(ts) < 199*time.Hour {
|
|
t.Fatalf("malformed record must carry the file mtime as its timestamp, got %q (%v)", r.RegisteredAt, err)
|
|
}
|
|
}
|
|
|
|
func TestListSessions_NewestFirst(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
self := os.Getpid()
|
|
parent := os.Getppid()
|
|
writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: self, PIDSource: "self"}, Agent: "older", Cwd: "/a", RegisteredAt: "2026-08-01T00:00:00Z"})
|
|
writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: parent, PIDSource: "CLAUDE_PID"}, Agent: "newer", Cwd: "/b", RegisteredAt: "2026-08-02T00:00:00Z"})
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != 2 || records[0].Agent != "newer" || records[1].Agent != "older" {
|
|
t.Fatalf("expected newest first, got %+v", records)
|
|
}
|
|
}
|
|
|
|
func TestPruneSessions(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
dead := exitedProcessPID(t)
|
|
alivePath := writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: os.Getpid(), PIDSource: "self"}, Agent: "live", Cwd: "/a", RegisteredAt: "2026-08-01T00:00:00Z"})
|
|
deadPath := writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: dead, PIDSource: "CLAUDE_PID"}, Agent: "gone", Cwd: "/b", RegisteredAt: "2026-08-01T00:00:00Z"})
|
|
unknownPath := filepath.Join(dir, "777777.json")
|
|
if err := os.WriteFile(unknownPath, []byte("garbage"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Now().Add(-100 * time.Hour)
|
|
if err := os.Chtimes(unknownPath, old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// No age bound: dead goes, alive and unknown stay.
|
|
rep, err := PruneSessions(0, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.DeadRemoved != 1 || rep.UnknownRemoved != 0 || rep.Kept != 2 || len(rep.Removed) != 1 || rep.Removed[0].Path != deadPath {
|
|
t.Fatalf("prune(0) report wrong: %+v", rep)
|
|
}
|
|
if _, err := os.Stat(deadPath); !os.IsNotExist(err) {
|
|
t.Fatalf("dead record must be removed; stat err = %v", err)
|
|
}
|
|
for _, p := range []string{alivePath, unknownPath} {
|
|
if _, err := os.Stat(p); err != nil {
|
|
t.Fatalf("%s must survive prune(0): %v", p, err)
|
|
}
|
|
}
|
|
|
|
// An age bound that the unknown record is YOUNGER than: still kept.
|
|
rep, err = PruneSessions(200*time.Hour, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.UnknownRemoved != 0 || rep.Kept != 2 {
|
|
t.Fatalf("young unknown must be kept under a 200h bound: %+v", rep)
|
|
}
|
|
// An age bound it is older than: removed. Alive is never touched.
|
|
rep, err = PruneSessions(50*time.Hour, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.UnknownRemoved != 1 || rep.DeadRemoved != 0 || rep.Kept != 1 {
|
|
t.Fatalf("old unknown must be removed under a 50h bound: %+v", rep)
|
|
}
|
|
if _, err := os.Stat(unknownPath); !os.IsNotExist(err) {
|
|
t.Fatalf("old unknown record must be removed; stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(alivePath); err != nil {
|
|
t.Fatalf("alive record must never be pruned: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSessionRecord_JSONShape pins the consumer contract of `pad session
|
|
// list --format json`: the keys scripts read, present in the shape they
|
|
// expect, with agent always present (never omitted when anonymous).
|
|
func TestSessionRecord_JSONShape(t *testing.T) {
|
|
registryEnv(t)
|
|
rec, err := RegisterSession("/proj", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, key := range []string{"session_pid", "session_pid_source", "session_pid_verified", "agent", "cwd", "liveness", "registered_at", "path"} {
|
|
if _, ok := raw[key]; !ok {
|
|
t.Fatalf("record JSON missing %q: %s", key, data)
|
|
}
|
|
}
|
|
if raw["agent"] != "" || raw["liveness"] != "alive" || raw["session_pid"] != float64(os.Getpid()) || raw["session_pid_verified"] != true {
|
|
t.Fatalf("record JSON values wrong: %s", data)
|
|
}
|
|
for _, absent := range []string{"legacy", "malformed", "session_id"} {
|
|
if _, ok := raw[absent]; ok {
|
|
t.Fatalf("%q must be omitted when false/empty: %s", absent, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestListSessions_SemanticallyMalformedIsUnknown: JSON that parses but
|
|
// is not a registration this code wrote must be MALFORMED (unknown, kept
|
|
// without an age bound) — not a dead legacy record to prune, and not a
|
|
// live one (codex round 1 P2).
|
|
func TestListSessions_SemanticallyMalformedIsUnknown(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := map[string]string{
|
|
"1001.json": `{}`,
|
|
"1002.json": `{"session_pid":1}`,
|
|
"1003.json": `{"session_pid":1,"registered_at":"not-a-time"}`,
|
|
"1004.json": `{"session_pid":-5,"registered_at":"2026-08-01T00:00:00Z"}`,
|
|
// Negative owner pid with every other required field present, so
|
|
// the pid check alone must reject it.
|
|
"1008.json": `{"session_pid":-5,"pid":1,"cwd":"/x","registered_at":"2026-08-01T00:00:00Z"}`,
|
|
"1005.json": `{"pid":0,"registered_at":"2026-08-01T00:00:00Z"}`,
|
|
// Fields every writer emits, missing: no cwd; no registrar pid.
|
|
"1006.json": `{"session_pid":1,"pid":1,"registered_at":"2026-08-01T00:00:00Z"}`,
|
|
"1007.json": `{"session_pid":1,"cwd":"/x","registered_at":"2026-08-01T00:00:00Z"}`,
|
|
}
|
|
for name, body := range cases {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != len(cases) {
|
|
t.Fatalf("expected %d records, got %+v", len(cases), records)
|
|
}
|
|
for _, r := range records {
|
|
if !r.Malformed || r.Liveness != LivenessUnknown {
|
|
t.Fatalf("%s must be malformed+unknown: %+v", filepath.Base(r.Path), r)
|
|
}
|
|
}
|
|
rep, err := PruneSessions(0, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rep.Removed) != 0 || rep.Kept != len(cases) {
|
|
t.Fatalf("an unbounded prune must keep every malformed file: %+v", rep)
|
|
}
|
|
}
|
|
|
|
// TestRegistry_LockFileIsNotASession: the mutation lock lives in the
|
|
// registry directory and must never list as a session.
|
|
func TestRegistry_LockFileIsNotASession(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
if _, err := RegisterSession("/p", "x"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, sessionsLockName)); err != nil {
|
|
t.Fatalf("register must take the directory lock (lock file missing): %v", err)
|
|
}
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != 1 {
|
|
t.Fatalf("only the registration may list, got %+v", records)
|
|
}
|
|
}
|
|
|
|
// TestRegistry_ConcurrentRegisterAndPrune: many registers and prunes at
|
|
// once, in-process, end with exactly the live record and no error. This
|
|
// exercises the lock + re-read path under contention; it cannot prove the
|
|
// cross-process race is closed (that is flock's contract, stated in
|
|
// session_lock_unix.go), only that the mutators do not corrupt each other.
|
|
func TestRegistry_ConcurrentRegisterAndPrune(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 200)
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(2)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
if _, err := RegisterSession(fmt.Sprintf("/d/%d", i), "a"); err != nil {
|
|
errs <- err
|
|
}
|
|
}(i)
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, err := PruneSessions(0, time.Now()); err != nil {
|
|
errs <- err
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Fatalf("concurrent mutation error: %v", err)
|
|
}
|
|
files := numericFiles(t, dir)
|
|
if len(files) != 1 || files[0] != fmt.Sprintf("%d.json", os.Getppid()) {
|
|
t.Fatalf("expected exactly the live record, got %v", files)
|
|
}
|
|
}
|
|
|
|
// TestListSessions_OrdersByInstantAcrossFormats: two registrations inside
|
|
// one second still order (nanosecond stamps), and a v2 nanosecond stamp
|
|
// interleaves correctly with a v1 whole-second one — compared as times,
|
|
// not strings ("…:08Z" would string-sort after "…:08.5Z").
|
|
func TestListSessions_OrdersByInstantAcrossFormats(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getpid()))
|
|
first, err := RegisterSession("/first", "a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
|
|
second, err := RegisterSession("/second", "b")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Pin the FORMAT directly so a coarse clock cannot make this leg
|
|
// vacuous: nanosecond stamps carry a fractional part (a whole-second
|
|
// writer never does; both stamps landing on an exact second is a
|
|
// one-in-1e18 event, not a flake source).
|
|
if !strings.Contains(first.RegisteredAt, ".") && !strings.Contains(second.RegisteredAt, ".") {
|
|
t.Fatalf("registrations must carry sub-second stamps, got %q and %q", first.RegisteredAt, second.RegisteredAt)
|
|
}
|
|
if first.RegisteredAt == second.RegisteredAt {
|
|
// A coarse clock can still hand two calls the same instant; the
|
|
// sort then falls to path order by design, and the ORDER leg has
|
|
// nothing to say (the format leg above already held).
|
|
t.Logf("two registrations shared one stamp (%q); skipping the same-second ordering leg", first.RegisteredAt)
|
|
} else {
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != 2 || records[0].Cwd != "/second" {
|
|
t.Fatalf("newest first within one second: %+v", records)
|
|
}
|
|
}
|
|
|
|
// Mixed formats: a v1 row at 08Z and a v2 row at 08.5Z in the same
|
|
// second — the v2 row is later and must list first.
|
|
for _, f := range numericFiles(t, dir) {
|
|
_ = os.Remove(filepath.Join(dir, f))
|
|
}
|
|
writeV1Record(t, dir, 50001, "") // registered_at 2026-08-20T00:00:00Z
|
|
writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: 50002, PIDSource: "self"}, Cwd: "/v2", RegisteredAt: "2026-08-20T00:00:00.5Z"})
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != 2 || records[0].Cwd != "/v2" {
|
|
t.Fatalf("v2 half-second-later row must list before the v1 whole-second row: %+v", records)
|
|
}
|
|
}
|
|
|
|
// TestListSessions_SymlinkIsNotASession: a session-shaped symlink is not
|
|
// read — it would present some other file (another record, or anything
|
|
// the link points at) as a session.
|
|
func TestListSessions_SymlinkIsNotASession(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink creation needs privileges on windows")
|
|
}
|
|
dir := registryEnv(t)
|
|
rec, err := RegisterSession("/real", "me")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(rec.Path, filepath.Join(dir, "31337.json")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != 1 || records[0].Path != rec.Path {
|
|
t.Fatalf("the symlink must not list as a second session: %+v", records)
|
|
}
|
|
}
|
|
|
|
// TestListSessions_ExplicitZeroPidIsLegacyWithoutName: JSON cannot tell
|
|
// session_pid:0 from an omitted one, so a crafted v2-looking file with a
|
|
// zero owner and an agent name is a LEGACY row — and a legacy row carries
|
|
// no name and no session id, whatever the file says.
|
|
func TestListSessions_ExplicitZeroPidIsLegacyWithoutName(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := fmt.Sprintf(`{"session_pid":0,"session_pid_verified":true,"agent":"impostor","session_id":"x","pid":%d,"cwd":"/c","registered_at":"2026-08-01T00:00:00Z"}`, os.Getpid())
|
|
if err := os.WriteFile(filepath.Join(dir, "777.json"), []byte(body), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(records) != 1 {
|
|
t.Fatalf("expected one row, got %+v", records)
|
|
}
|
|
r := records[0]
|
|
if !r.Legacy || r.Agent != "" || r.SessionID != "" || r.SessionPIDVerified || r.SessionPID != os.Getpid() {
|
|
t.Fatalf("zero-pid row must be legacy with no name/id/verification: %+v", r)
|
|
}
|
|
}
|
|
|
|
// TestSessionsDir_TightensExistingMode: a pre-existing permissive
|
|
// directory is re-tightened to 0700 on every use.
|
|
func TestSessionsDir_TightensExistingMode(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("unix permission bits")
|
|
}
|
|
want := registryEnv(t)
|
|
if err := os.MkdirAll(want, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir, err := SessionsDir()
|
|
if err != nil || dir != want {
|
|
t.Fatalf("SessionsDir: %q %v", dir, err)
|
|
}
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0700 {
|
|
t.Fatalf("existing directory must be tightened to 0700, got %o", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
// TestReadSessionRecord_InputHardening: an oversized file, invalid UTF-8,
|
|
// and a FIFO under a session-shaped name are all MALFORMED — and the FIFO
|
|
// does not block the reader (codex round 8).
|
|
func TestReadSessionRecord_InputHardening(t *testing.T) {
|
|
dir := registryEnv(t)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Oversized but otherwise VALID: without the size bound this parses as
|
|
// a well-formed live record (owner pid = ours), so only the bound can
|
|
// make it malformed — a fixture that also failed to parse would not
|
|
// discriminate (the first draft of this test did exactly that).
|
|
big := filepath.Join(dir, "2001.json")
|
|
bigBody := fmt.Sprintf(`{"session_pid":%d,"pid":1,"cwd":"/x","registered_at":"2026-08-01T00:00:00Z","agent":"%s"}`, os.Getpid(), strings.Repeat("a", maxRegistryRecordBytes))
|
|
if err := os.WriteFile(big, []byte(bigBody), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bad := filepath.Join(dir, "2002.json")
|
|
if err := os.WriteFile(bad, []byte("{\"session_pid\":1,\"pid\":1,\"cwd\":\"/x\",\"registered_at\":\"2026-08-01T00:00:00Z\",\"agent\":\"\xff\xfe\"}"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{big, bad} {
|
|
rec, err := readSessionRecord(p)
|
|
if err != nil || !rec.Malformed || rec.Liveness != LivenessUnknown {
|
|
t.Fatalf("%s must read as malformed/unknown: %+v %v", filepath.Base(p), rec, err)
|
|
}
|
|
}
|
|
|
|
fifo := filepath.Join(dir, "2003.json")
|
|
if err := mkfifo(fifo); err != nil {
|
|
t.Skipf("no fifo on this platform: %v", err)
|
|
}
|
|
done := make(chan SessionRecord, 1)
|
|
go func() { rec, _ := readSessionRecord(fifo); done <- rec }()
|
|
select {
|
|
case rec := <-done:
|
|
if !rec.Malformed {
|
|
t.Fatalf("a FIFO must read as malformed, got %+v", rec)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("reading a FIFO under a session name must not block")
|
|
}
|
|
// And the directory listing skips it by type, so it never lists.
|
|
records, err := ListSessions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range records {
|
|
if r.Path == fifo {
|
|
t.Fatalf("FIFO must not list: %+v", r)
|
|
}
|
|
}
|
|
}
|