mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +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
250 lines
9.9 KiB
Go
250 lines
9.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
)
|
|
|
|
// Probe failures that mean "the owner is GONE" (as opposed to "the owner
|
|
// could not be examined"). See pidLiveness.
|
|
var (
|
|
errProcZombie = errors.New("process is a zombie")
|
|
errProcStatMalformed = errors.New("/proc stat is malformed")
|
|
)
|
|
|
|
// Session owner identity (TASK-2767, IDEA-2750 part 2).
|
|
//
|
|
// Two local files describe "the session this process belongs to": the
|
|
// arm-state file (session_arm_state.go) and the session registry
|
|
// (session_registry.go). Both must answer the same question — is the
|
|
// OWNER of this record still alive, and is it the SAME owner that wrote
|
|
// it — and they used to answer it in two places with two different
|
|
// vocabularies. SessionOwner is the one identity they share, and
|
|
// OwnerLiveness is the one verdict.
|
|
//
|
|
// WHAT "OWNER" MEANS. The process a short-lived `pad` command was run
|
|
// FROM — an agent harness's session process — not the `pad` command
|
|
// itself, whose pid is dead before anyone reads the record. The harness
|
|
// names it: Claude Code exports CLAUDE_PID; any other harness can export
|
|
// PAD_SESSION_PID (the runtime-agnostic override, exactly as PAD_AGENT is
|
|
// for the agent name). With neither, the owner is this process — correct
|
|
// for a long-lived `pad` process such as a monitor, and the documented
|
|
// residual for a bare shell.
|
|
//
|
|
// WHY LIVENESS IS TRI-STATE. A consent gate and a reaper need opposite
|
|
// defaults. A consent gate treats anything uncertain as dead (a stale file
|
|
// must never arm a future session — PLAN-2613 constraint 2). A reaper
|
|
// treating uncertain as dead would delete every live record on a platform
|
|
// where pids cannot be probed (pidAlive reports dead for every pid on
|
|
// Windows). So the verdict carries its uncertainty, and each consumer
|
|
// applies its own posture: armStateOwnerAlive is `== LivenessAlive`;
|
|
// PruneSessions deletes only LivenessDead and leaves LivenessUnknown to
|
|
// an explicit age bound.
|
|
|
|
// Liveness is the verdict on a recorded session owner.
|
|
type Liveness string
|
|
|
|
const (
|
|
// LivenessAlive: the recorded owner exists now AND its identity matches
|
|
// what was recorded (socket inode/mtime, or pid + process start token).
|
|
LivenessAlive Liveness = "alive"
|
|
// LivenessDead: the owner is gone, or the thing at its address is a
|
|
// different owner (a rebound socket, a reused pid).
|
|
LivenessDead Liveness = "dead"
|
|
// LivenessUnknown: this platform cannot probe the owner at all. Not
|
|
// dead — a reaper must not act on it — and not alive — a gate must not
|
|
// trust it.
|
|
LivenessUnknown Liveness = "unknown"
|
|
)
|
|
|
|
// SessionOwner identifies the session a record belongs to, with enough
|
|
// identity to tell a reused address from the original. JSON tags are the
|
|
// session registry's on-disk keys (messaging_socket_path is the v1 key,
|
|
// kept so legacy files still parse).
|
|
type SessionOwner struct {
|
|
// PID is the owner process. Zero in a legacy registry file.
|
|
PID int `json:"session_pid,omitempty"`
|
|
// PIDSource records where PID came from: "PAD_SESSION_PID", "CLAUDE_PID",
|
|
// or "self" (os.Getpid()). Legacy records derive one — see
|
|
// legacyOwner. Recorded because a self-keyed record from a short-lived
|
|
// command is dead by the time anyone reads it, and a reader should be
|
|
// able to see that this is why.
|
|
PIDSource string `json:"session_pid_source,omitempty"`
|
|
// PIDVerified is true when the claimed pid was checked against this
|
|
// process's ancestry at capture time (pidIsSelfOrAncestor — Linux) or
|
|
// is this process itself. A harness-supplied pid is otherwise a bare
|
|
// claim: any positive integer names SOME process, and the start token
|
|
// only guards against later reuse, not against a wrong pid in the
|
|
// first place (codex round 3). False on platforms without an ancestry
|
|
// walk, and for legacy rows.
|
|
PIDVerified bool `json:"session_pid_verified,omitempty"`
|
|
// ProcStart is the owner's process start token (procStartToken) when
|
|
// the platform supplies one — the pid-reuse defence. Empty elsewhere.
|
|
ProcStart string `json:"proc_start,omitempty"`
|
|
// Socket is CLAUDE_CODE_MESSAGING_SOCKET at capture time, recorded only
|
|
// when the socket existed then (its identity below is what makes it a
|
|
// liveness signal; a path alone proves nothing).
|
|
Socket string `json:"messaging_socket_path,omitempty"`
|
|
// SocketMtimeUnixNano, SocketIno, SocketDev bind the record to the
|
|
// specific socket INSTANCE — the same identity the arm-state file uses
|
|
// (Codex R1 HIGH-2 / R2 finding 2 on PLAN-2613 S2): a socket rebound at
|
|
// the same path is a different session.
|
|
SocketMtimeUnixNano int64 `json:"socket_mtime_unix_nano,omitempty"`
|
|
SocketIno uint64 `json:"socket_ino,omitempty"`
|
|
SocketDev uint64 `json:"socket_dev,omitempty"`
|
|
}
|
|
|
|
// CaptureSessionOwner reads this process's session owner from the
|
|
// environment. Precedence for the pid, most explicit first:
|
|
//
|
|
// 1. $PAD_SESSION_PID — the harness-agnostic override.
|
|
// 2. $CLAUDE_PID — Claude Code's own export (verified against a live
|
|
// session: present in the tool shell AND in the plugin monitor's
|
|
// environment).
|
|
// 3. os.Getpid() — this process.
|
|
//
|
|
// A set-but-invalid value is an error, not a fall-through: a harness that
|
|
// exports garbage would otherwise key every record on a dead command pid
|
|
// while reporting success, which is the silent misregistration this whole
|
|
// unit exists to end.
|
|
func CaptureSessionOwner() (SessionOwner, error) {
|
|
o := SessionOwner{PID: os.Getpid(), PIDSource: "self"}
|
|
for _, env := range []string{"PAD_SESSION_PID", "CLAUDE_PID"} {
|
|
v := os.Getenv(env)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n <= 0 {
|
|
return SessionOwner{}, fmt.Errorf("%s=%q is not a positive integer", env, v)
|
|
}
|
|
o.PID, o.PIDSource = n, env
|
|
break
|
|
}
|
|
if o.PIDSource == "self" {
|
|
o.PIDVerified = true
|
|
} else {
|
|
// A wrong-but-live pid must not be recorded as if it were checked;
|
|
// an unreadable /proc leaves it unverified too.
|
|
o.PIDVerified, _ = pidIsSelfOrAncestor(o.PID)
|
|
}
|
|
// Best effort: empty where the platform has no token, and empty when
|
|
// the pid cannot be read (a harness pid we cannot see is recorded
|
|
// without a token and judged by bare liveness, the documented residual).
|
|
o.ProcStart, _ = procStartToken(o.PID)
|
|
|
|
if sock := os.Getenv("CLAUDE_CODE_MESSAGING_SOCKET"); sock != "" {
|
|
if info, err := os.Stat(sock); err == nil {
|
|
o.Socket = sock
|
|
o.SocketMtimeUnixNano = info.ModTime().UnixNano()
|
|
if ino, dev, ok := statIdentity(info); ok {
|
|
o.SocketIno, o.SocketDev = ino, dev
|
|
}
|
|
}
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// OwnerLiveness is the shared verdict. EVERY recorded signal must agree:
|
|
// the socket (when recorded) must still exist with the identity captured
|
|
// at registration — inode/device, else mtime, so a reused path is a
|
|
// different session — AND the pid (when recorded) must be alive and, when
|
|
// a start token was recorded, still carry it. Either signal dead → dead.
|
|
// Neither alone suffices for the registry: a socket node outlives a
|
|
// SIGKILLed owner (the kernel does not unlink it), so a socket-only verdict
|
|
// would report a crashed harness alive indefinitely (codex round 1 P1);
|
|
// and a pid alone cannot exclude reuse on platforms without a start token.
|
|
//
|
|
// Unknown is the third answer, and it is reserved for "could not examine",
|
|
// never "not sure it is alive": a platform that cannot probe pids
|
|
// (Windows), a socket the caller cannot stat (a permission or I/O error,
|
|
// as opposed to ENOENT), or a /proc entry that cannot be read (hidepid, a
|
|
// namespace boundary). A dead verdict is only ever issued on positive
|
|
// evidence of absence or of a different owner (P2). The two consumers
|
|
// then choose: the consent gate treats unknown as not-armed, the pruner
|
|
// leaves it alone.
|
|
//
|
|
// A record with NO signal at all (no socket, no pid) is dead.
|
|
func OwnerLiveness(o *SessionOwner) Liveness {
|
|
if o == nil || (o.Socket == "" && o.PID <= 0) {
|
|
return LivenessDead
|
|
}
|
|
verdict := LivenessAlive
|
|
if o.Socket != "" {
|
|
switch socketLiveness(o) {
|
|
case LivenessDead:
|
|
return LivenessDead
|
|
case LivenessUnknown:
|
|
verdict = LivenessUnknown
|
|
}
|
|
}
|
|
if o.PID > 0 {
|
|
switch pidLiveness(o) {
|
|
case LivenessDead:
|
|
return LivenessDead
|
|
case LivenessUnknown:
|
|
verdict = LivenessUnknown
|
|
}
|
|
}
|
|
return verdict
|
|
}
|
|
|
|
// socketLiveness judges the recorded socket instance.
|
|
func socketLiveness(o *SessionOwner) Liveness {
|
|
info, err := os.Stat(o.Socket)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return LivenessDead // socket vanished — session gone
|
|
}
|
|
return LivenessUnknown // could not examine (EACCES, EIO, ...)
|
|
}
|
|
if o.SocketMtimeUnixNano == 0 {
|
|
return LivenessDead // no identity recorded — cannot prove it is ours
|
|
}
|
|
if o.SocketIno != 0 {
|
|
if ino, dev, ok := statIdentity(info); ok {
|
|
if ino == o.SocketIno && dev == o.SocketDev {
|
|
return LivenessAlive
|
|
}
|
|
return LivenessDead // rebound at the same path — a different session
|
|
}
|
|
}
|
|
if info.ModTime().UnixNano() == o.SocketMtimeUnixNano {
|
|
return LivenessAlive
|
|
}
|
|
return LivenessDead
|
|
}
|
|
|
|
// pidLiveness judges the recorded owner pid. A live pid with no token
|
|
// recorded is alive on bare liveness — the documented residual for records
|
|
// written where no start token exists (non-Linux) and for legacy rows,
|
|
// which never had one; pid reuse cannot be excluded there.
|
|
func pidLiveness(o *SessionOwner) Liveness {
|
|
if runtime.GOOS == "windows" {
|
|
// pidAlive cannot probe here (Signal(0) is unsupported), so a
|
|
// verdict either way would be invented.
|
|
return LivenessUnknown
|
|
}
|
|
if !pidAlive(o.PID) {
|
|
return LivenessDead
|
|
}
|
|
if o.ProcStart == "" {
|
|
return LivenessAlive
|
|
}
|
|
now, err := procStartTokenErr(o.PID)
|
|
switch {
|
|
case err == nil:
|
|
if now == o.ProcStart {
|
|
return LivenessAlive
|
|
}
|
|
return LivenessDead // a reused pid — a different process
|
|
case errors.Is(err, os.ErrNotExist), errors.Is(err, errProcZombie):
|
|
return LivenessDead // gone between the signal probe and the read, or exited awaiting reap
|
|
default:
|
|
return LivenessUnknown // /proc unreadable or unsupported here — not evidence of absence
|
|
}
|
|
}
|