mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +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
74 lines
2.7 KiB
Go
74 lines
2.7 KiB
Go
//go:build linux
|
|
|
|
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// procStartToken returns a stable owner-identity token for a pid on
|
|
// Linux: the process's start time (field 22 of /proc/<pid>/stat, in clock
|
|
// ticks since boot). It is constant for the life of a process and differs
|
|
// for a reused pid, so comparing it defeats the pid-reuse hazard the
|
|
// headless arm-state fallback would otherwise have (Codex R1 HIGH-2). ok
|
|
// is false when the value can't be read, in which case the caller treats
|
|
// the owner as unverifiable and fails closed.
|
|
//
|
|
// A ZOMBIE (state 'Z') reports ok=false even though its /proc entry and
|
|
// start time still exist: the arming process has exited and is only
|
|
// awaiting reap, so its consent is dead. Without this a defunct arm
|
|
// command would keep a headless session armed until its parent reaped it
|
|
// (Codex R2 finding 3).
|
|
//
|
|
// The comm field (2) is wrapped in parentheses and may itself contain
|
|
// spaces or parentheses, so parsing starts after the LAST ')': the fields
|
|
// that follow are space-separated. State is the 3rd field overall (index
|
|
// 0 after comm) and starttime is the 22nd (index 19 after comm).
|
|
func procStartToken(pid int) (string, bool) {
|
|
tok, err := procStartTokenErr(pid)
|
|
return tok, err == nil
|
|
}
|
|
|
|
// procStartTokenErr is procStartToken with the failure preserved, so a
|
|
// caller that must tell "this process is gone" (os.ErrNotExist, errProcZombie)
|
|
// from "this process could not be examined" (a permission or I/O error —
|
|
// hidepid mounts, a namespace boundary) can do so. The registry's liveness
|
|
// verdict needs that distinction (TASK-2767, codex round 1 P2): a probe that
|
|
// failed is not proof the owner is gone.
|
|
func procStartTokenErr(pid int) (string, error) {
|
|
fields, err := procStatFields(pid)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
const (
|
|
stateIndexAfterComm = 0 // field 3
|
|
startTimeIndexAfterComm = 19 // field 22, minus pid(1) and comm(2)
|
|
)
|
|
if len(fields) <= startTimeIndexAfterComm {
|
|
return "", errProcStatMalformed
|
|
}
|
|
if fields[stateIndexAfterComm] == "Z" {
|
|
return "", errProcZombie // the process has exited and awaits reap
|
|
}
|
|
return fields[startTimeIndexAfterComm], nil
|
|
}
|
|
|
|
// procStatFields reads /proc/<pid>/stat and returns the space-separated
|
|
// fields AFTER the comm field — the one parser both the start-token and
|
|
// the ancestry readers use, so the "last ')'" subtlety lives in one place.
|
|
// Index 0 is field 3 (state), index 1 field 4 (ppid), and so on.
|
|
func procStatFields(pid int) ([]string, error) {
|
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := string(data)
|
|
rparen := strings.LastIndexByte(s, ')')
|
|
if rparen < 0 || rparen+1 >= len(s) {
|
|
return nil, errProcStatMalformed
|
|
}
|
|
return strings.Fields(s[rparen+1:]), nil
|
|
}
|