mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
e40df6b31c
* feat(cli): pad session arm/disarm/status + consent config resolution (PLAN-2613 S2, TASK-2617) The S2 CLI contract S3's plugin skills and S4's web composer build against. S1 gated push delivery on a server-side armed bit declared at stream connect; nothing decided WHETHER to arm or sent the declaration. S2 adds both, defaulting off everywhere. - ResolveAutoArm (internal/cli/arm_consent.go): pure consent resolver. .pad.toml [push] auto_arm is the only per-repo enabler (D4); a per-user config auto_arm=false vetoes it (deny-wins); default off. Config surfaces: PadToml.Push.AutoArm + config.Config.Push.AutoArm (*bool, unset != false), both nil-safe. - Wire contract: StreamSessionIdentity.Armed sends ?armed=true on the event stream — S1's server gate finally has a sender. The monitor announces armed = live local arm OR resolved auto_arm, so a repo opt-in works end to end with a safe default-off skew. - Verbs pad session arm/disarm/status: arm/disarm manage a per-session local arm-state file; status reports the resolved local/auto decision plus the server's own armed/connected counts (new Client.ListSessions), degrading gracefully when padd is unreachable. - Arm-state file (session_arm_state.go): keyed per session by CLAUDE_CODE_MESSAGING_SOCKET (cwd fallback for headless, secondary to auto_arm). Mandatory liveness — a dead-owner file (socket vanished / pid gone) reads as disarmed and is reaped, so a crashed session can never arm a future monitor. Local client state only; the server's armed bit stays the sole delivery authority. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(cli): address Codex R1 on push-consent (fail-closed config, owner-identity liveness) - HIGH-1: user config.toml read now fails CLOSED. config.LoadPushConfigAutoArm reads the [push] auto_arm value strictly — absent → no opinion, but present-but-unparseable → error — and ResolveAutoArmFromDisk refuses to auto-arm when it can't confirm the user's veto (was: swallowed by the lenient config.Load and treated as no-opinion). - HIGH-2: arm-state liveness now checks owner IDENTITY, not just presence. Socket-keyed files record the socket's mtime and require an exact match, so a reused socket path can't revive a stale file. Headless files record a Linux /proc start-time token (portable fallback documented) to reject a reused pid. - MED-1: arm-state writes are atomic (temp + rename) and reaping is non-destructive (re-checks staleness before removing) — a concurrent re-arm is never clobbered. - MED-2: pad session status applies the .pad.toml URL override, so it queries the same server the monitor connects to. - LOW: malformed arm-state files are now reaped (safe now that writes are atomic — a corrupt file can't be a torn in-progress write). Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(cli): address Codex R2 on push-consent (atomic config write, stronger owner identity) - HIGH-1: Config.Save() is now atomic (temp + rename), so a monitor reconnecting while `pad configure` rewrites config.toml can't read a truncated/partial file, miss a [push] auto_arm=false veto, and arm. - finding 2: socket owner identity now uses inode+device (unix) as the primary signal, with mtime as the non-unix fallback — a rebound socket or a lingering stale node at the same path gets a new inode and is rejected, closing the mtime-collision / reused-node gaps. - finding 3: headless liveness fails closed when a proc-start token was recorded but can't be re-verified (was: fell back to bare pid-liveness, which a reused pid passes); zombies (state 'Z') now report not-alive. - finding 5: `pad session status` applies an explicit --url override too, not just the .pad.toml one. - finding 4 (connect-time TOCTOU): documented as an accepted, bounded residual — a disarm racing an in-flight connect is corrected on the next reconnect; fully closing it needs S3's server-side disarm-on-open signal. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
52 lines
1.8 KiB
Go
52 lines
1.8 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) {
|
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
s := string(data)
|
|
rparen := strings.LastIndexByte(s, ')')
|
|
if rparen < 0 || rparen+1 >= len(s) {
|
|
return "", false
|
|
}
|
|
fields := strings.Fields(s[rparen+1:])
|
|
const (
|
|
stateIndexAfterComm = 0 // field 3
|
|
startTimeIndexAfterComm = 19 // field 22, minus pid(1) and comm(2)
|
|
)
|
|
if len(fields) <= startTimeIndexAfterComm {
|
|
return "", false
|
|
}
|
|
if fields[stateIndexAfterComm] == "Z" {
|
|
return "", false // zombie: the arming process has exited
|
|
}
|
|
return fields[startTimeIndexAfterComm], true
|
|
}
|