Files
pad/internal/cli/session_arm_state.go
T
xarmian 9059862431 fix(session): a headless disarm holds for as long as its session lives: an unverifiable owner fails closed and is kept, and the file names the session, not the exiting command (BUG-2771, BUG-3227) (#1574)
* ci(smoke): a headless disarm on Windows must survive later reads (BUG-2771)

Added before the fix, so the first CI run shows it red against the
pre-fix binary.

* fix(session): an arm-state file whose session cannot be verified is kept and fails closed, so a disarm is never forgotten (BUG-2771)

The unknown owner verdict (every headless file on Windows; on unix an
unexaminable /proc entry or socket) was treated as dead: the next read
reaped the file and auto_arm re-armed over an explicit disarm. It now
resolves to LocalArmUnverifiable, which is not armed and not reaped.
`pad session arm` reports the real state, `status` explains it, and a
new `pad session reset` removes the kept file so auto_arm decides again.

* ci(smoke): the headless-disarm step covers unix too, owned by the step's shell via PAD_SESSION_PID (BUG-3227)

Pushed on top of the BUG-2771 fix but before BUG-3227's, so this run
shows the unix path red pre-fix while Windows turns green.

* fix(session): a headless arm or disarm is owned by the session process, not the exiting command, and a write no session can own is refused (BUG-3227)

A headless arm-state file recorded os.Getpid(), the pid of the
short-lived `pad session arm`/`disarm` itself, so on unix it was dead on
the next read and reaped: an explicit disarm never held. It now records
CaptureSessionOwner() (PAD_SESSION_PID, then CLAUDE_PID), as the session
registry does. With no session process to name, the owner is the
command, and arm/disarm refuse rather than report a consent change that
did not happen.

* docs(session): arm and disarm --help say how a headless session is identified, and that with no owner they exit non-zero (BUG-3227)
2026-09-26 11:55:16 -04:00

599 lines
27 KiB
Go

package cli
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Local arm-state file (PLAN-2613 S2, D4; lead ruling "OPTION 2" on the
// TASK-2617 trail). This is the concrete file contract S3's plugin
// monitor builds against, so its rules are load-bearing and stated in
// full here (and mirrored in the S2 evidence package):
//
// - WHAT IT IS. One file per SESSION recording that the session has
// armed — declared consent to receive `pad push` notifications. A
// separate process (S3's monitor) reads it to decide whether the
// stream it opens should announce armed=true. It is how a short-lived
// `pad session arm` command hands its intent to the long-lived
// monitor that actually holds the stream.
//
// - IT IS LOCAL CLIENT STATE, NOT SERVER STATE (constraint 4). It feeds
// exactly one thing: the ?armed=true query param the client sends at
// connect. The SERVER's per-connection armed bit (PLAN-2613 D3, built
// in S1) remains the SOLE authority for whether a push is delivered.
// No reader may treat this file as proof that any server-side session
// is armed — the server never sees it.
//
// - LIVENESS IS MANDATORY (constraint 2, non-negotiable). A file whose
// OWNER is dead is treated as DISARMED and may be removed by any
// reader. "Owner dead" means: the recorded messaging socket has
// vanished (the Claude Code session it belonged to is gone), or — for
// the headless fallback with no socket — the recorded pid is no
// longer running. This is the whole reason the file carries owner
// identity rather than a bare bit: a crashed session's stale armed
// file must NEVER arm a future monitor. That would be consent-
// grandfathering through the filesystem, exactly the silent re-arm
// PLAN-2613 D6 forbids. When in doubt the check fails CLOSED (treats
// the owner as dead / the session as disarmed).
//
// - KEYED PER SESSION (constraint 1). The key is derived from
// CLAUDE_CODE_MESSAGING_SOCKET, which both the arming command and the
// monitor see because they run in the same Claude Code session. With
// no socket (a headless agent), the key falls back to the working
// directory — PER-REPO semantics, documented as secondary: the
// sanctioned headless arming path is .pad.toml auto_arm (D4), not
// this file. A headless file is owned by the SESSION process the
// harness names (PAD_SESSION_PID, else CLAUDE_PID — CaptureSessionOwner),
// never by the short-lived `pad session arm` itself, whose pid is dead
// before anyone reads the file (BUG-3227). With no harness to name
// one, the owner is the command and the file is dead on arrival; the
// arm/disarm verbs read their state back and refuse in that case
// rather than report a consent change that did not happen.
//
// - LIFECYCLE (constraint 3). arm writes/overwrites the file
// (idempotent). disarm REWRITES it as an explicit OFF marker (S3 —
// see ArmState.Disarmed; removing it would let auto_arm re-arm), and
// a dead owner gets it reaped. Both verbs are ordinary CLI actions
// visible in the session transcript (D7).
// ArmState is one session's on-disk arm declaration. Presence of the file
// (with a live owner) identifies the session; Armed/Disarmed say what it
// declared (S3 made this tri-state — see Disarmed), and the other fields
// exist to prove the owner is still alive. Armed is serialized explicitly
// so a human inspecting this security artifact reads intent at a glance.
type ArmState struct {
Armed bool `json:"armed"`
// Disarmed makes the file TRI-STATE (PLAN-2613 S3). S2 had two states:
// a present (armed) file or none. S3 adds an explicit-OFF value so a
// within-session `pad session disarm` can withdraw consent even in an
// auto_arm=true repo — the disconnect verb must not be a lie there. A
// file with Disarmed=true is a session-scoped explicit OFF that BEATS
// auto_arm for its owner session, under the SAME liveness rules as an
// armed file: it dies with the session (reaped when the owner is dead),
// so across sessions auto_arm remains the standing contract (permanent
// off is a .pad.toml edit, symmetric with how it turned on). When
// Disarmed is true the Armed marker is false; presence with a live
// owner still identifies the session, the Disarmed flag decides the
// meaning. See SessionArmState.
Disarmed bool `json:"disarmed,omitempty"`
// Booted records that this session has already had its first-connect
// boot ritual fired (PLAN-2613 D8): /pad:connect injects the
// workspace's on-session-start playbooks on the FIRST connect only, and
// re-arms/reconnects must not re-fire it. It is per-session state that
// dies with the session (the whole file is reaped when the owner dies),
// and it is carried forward across arm/disarm rewrites so toggling
// consent doesn't reset the boot flag. See MarkFirstConnect.
Booted bool `json:"booted,omitempty"`
// PID: for a socket-keyed session, the process that wrote the file,
// informational only (the socket is the liveness signal). For the
// headless fallback it IS the liveness signal, so it is the SESSION
// owner's pid (see PIDSource), not the writing command's (BUG-3227).
PID int `json:"pid"`
// PIDSource is where a headless file's PID came from: "PAD_SESSION_PID",
// "CLAUDE_PID", or "self" (BUG-3227). Diagnostic only; "self" means the
// file names the command that wrote it and is dead on arrival.
PIDSource string `json:"session_pid_source,omitempty"`
// Socket is CLAUDE_CODE_MESSAGING_SOCKET at arm time, or "" for the
// headless fallback. When set, its continued existence on disk is a
// necessary liveness signal — it outlives the short-lived arm command
// and vanishes with the Claude Code session.
Socket string `json:"socket,omitempty"`
// SocketMtimeUnixNano is the socket file's modification time at arm
// time (0 when headless). Existence alone is not enough: a socket
// PATH can be reused by a later, unrelated Claude Code session, and a
// stale arm file keyed on that path would then arm the new session —
// consent-grandfathering (Codex R1 HIGH-2). The socket file's mtime is
// its bind (creation) time, so a reused path is a DIFFERENT socket
// with a different mtime; the reader requires an exact match, which
// ties the file to the specific socket instance it was armed for.
SocketMtimeUnixNano int64 `json:"socket_mtime_unix_nano,omitempty"`
// SocketIno / SocketDev are the socket node's inode and device at arm
// time (0 on platforms where they can't be read). They are the
// STRONGEST identity signal: a socket rebound at the same path gets a
// new inode, so this rejects both a lingering stale node reused as-is
// and an mtime collision on a coarse-resolution filesystem (Codex R2
// finding 2). Where unavailable (non-unix), the reader falls back to
// the mtime check alone — the documented residual on those platforms.
SocketIno uint64 `json:"socket_ino,omitempty"`
SocketDev uint64 `json:"socket_dev,omitempty"`
// ProcStart is an opaque owner-identity token for the HEADLESS
// fallback (empty when socket-keyed, or when the platform can't supply
// one). It disambiguates PID reuse: a bare "is this pid alive" check
// can't tell the original arming process from an unrelated later
// process that happens to reuse its pid. On Linux this is the
// process's start time from /proc; where unavailable it is empty and
// the reader falls back to bare pid-liveness (the documented residual
// on the secondary path — see armStateOwnerAlive).
ProcStart string `json:"proc_start,omitempty"`
// Cwd is the working directory at arm time — the fallback key's source
// and useful human context when inspecting the file.
Cwd string `json:"cwd"`
// StartedAt is RFC3339 UTC.
StartedAt string `json:"started_at"`
}
// armStateKey derives the per-session key and reports whether the
// headless (per-repo) fallback was used. socket wins when present; cwd is
// the fallback. Both are hashed so the filename is filesystem-safe and
// leaks neither the socket path nor the absolute cwd into a directory
// listing.
func armStateKey(socket, cwd string) (key string, headless bool) {
if socket != "" {
return "sess-" + shortHash(socket), false
}
return "repo-" + shortHash(cwd), true
}
func shortHash(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])[:16]
}
// armStatePath returns the on-disk path for this session's arm-state
// file, creating ~/.pad/sessions if needed.
func armStatePath(socket, cwd string) (string, error) {
dir, err := SessionsDir()
if err != nil {
return "", err
}
key, _ := armStateKey(socket, cwd)
return filepath.Join(dir, "arm-"+key+".json"), nil
}
// currentSessionArmKey returns the (socket, cwd) pair identifying THIS
// process's session, read from the environment and the working directory.
// A cwd error degrades to "" — the arm file just loses its human-readable
// cwd and, in the headless case, its key; callers that need cwd for
// keying handle the empty case by refusing (see WriteArmState).
func currentSessionArmKey() (socket, cwd string) {
socket = os.Getenv("CLAUDE_CODE_MESSAGING_SOCKET")
cwd, _ = os.Getwd()
return socket, cwd
}
// WriteArmState arms the current session (an explicit ON marker). See
// writeArmStateFile.
func WriteArmState() (path string, err error) {
return writeArmStateFile(false)
}
// WriteDisarmState writes a session-scoped explicit-OFF marker (PLAN-2613
// S3): the session withdraws consent for its remaining life, beating a
// repo's auto_arm opt-in, and the marker dies with the session under the
// same liveness rules (see ArmState.Disarmed). This is what `pad session
// disarm` writes — it does NOT remove the file, because absence means
// "resolve auto_arm", which in an auto_arm repo would immediately re-arm.
func WriteDisarmState() (path string, err error) {
return writeArmStateFile(true)
}
// writeArmStateFile writes (or idempotently overwrites) this session's
// arm-state file, mode 0600, with the given disarmed value, and returns
// the path. It refuses only when there is no key to write under at all —
// no messaging socket AND no working directory — because a file with
// neither could never be matched to a session or a repo by a reader.
func writeArmStateFile(disarmed bool) (path string, err error) {
socket, cwd := currentSessionArmKey()
if socket == "" && cwd == "" {
return "", fmt.Errorf("cannot record arm state: no messaging socket and no working directory to key the session on")
}
p, err := armStatePath(socket, cwd)
if err != nil {
return "", err
}
// Carry the boot flag forward across arm/disarm rewrites so toggling
// consent within a session doesn't re-fire the first-connect ritual
// (D8). Only a LIVE prior file counts — a dead-owner one is a stale
// session and its boot state is irrelevant.
booted := false
if prev, _, rerr := readArmState(); rerr == nil && prev != nil && armStateOwnerAlive(prev) {
booted = prev.Booted
}
st := ArmState{
Armed: !disarmed,
Disarmed: disarmed,
Booted: booted,
PID: os.Getpid(),
Socket: socket,
Cwd: cwd,
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
if socket != "" {
// Bind the file to this specific socket instance — its mtime plus,
// where available, its inode+device — not just the path, so a
// reused path (or a lingering stale node) can't revive it (HIGH-2,
// R2 finding 2).
if info, statErr := os.Stat(socket); statErr == nil {
st.SocketMtimeUnixNano = info.ModTime().UnixNano()
if ino, dev, ok := statIdentity(info); ok {
st.SocketIno, st.SocketDev = ino, dev
}
}
} else {
// Headless: the owner is the session process, not this command
// (BUG-3227). os.Getpid() here is the exiting `pad session arm`,
// which read as dead on the very next read, so every headless arm
// and disarm was reaped on arrival. CaptureSessionOwner resolves
// the same owner the session registry records, with its start
// token for the pid-reuse defence.
owner, oerr := CaptureSessionOwner()
if oerr != nil {
return "", fmt.Errorf("cannot record arm state: %w", oerr)
}
st.PID, st.ProcStart, st.PIDSource = owner.PID, owner.ProcStart, owner.PIDSource
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return "", fmt.Errorf("marshal arm state: %w", err)
}
// Atomic write: a reader must never observe a half-written file (a
// partial file would be an unparseable "malformed" state, and now that
// readers reap malformed files, a torn write could be reaped mid-arm).
// Write to a temp file in the same directory, then rename — rename is
// atomic within a filesystem.
if err := atomicWriteFile(p, data, 0600); err != nil {
return "", fmt.Errorf("write arm state: %w", err)
}
return p, nil
}
// atomicWriteFile writes data to a temp file in path's directory and
// renames it into place, so a concurrent reader sees either the old file
// or the complete new one, never a partial write.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".arm-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op after a successful rename
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// RemoveArmState disarms the current session by removing its arm-state
// file. It reports whether a file was actually present (so `pad session
// disarm` can tell the user "disarmed" vs "was not armed") and never
// treats a missing file as an error — disarm is idempotent.
func RemoveArmState() (removed bool, path string, err error) {
socket, cwd := currentSessionArmKey()
p, err := armStatePath(socket, cwd)
if err != nil {
return false, "", err
}
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
return false, p, nil
}
return false, p, fmt.Errorf("remove arm state: %w", err)
}
return true, p, nil
}
// readArmState reads and unmarshals the current session's arm-state file.
// A missing file returns (nil, path, nil) — the common "not armed" case,
// not an error. A malformed file returns an error the caller folds into
// "not armed" (fail closed).
func readArmState() (st *ArmState, path string, err error) {
socket, cwd := currentSessionArmKey()
p, err := armStatePath(socket, cwd)
if err != nil {
return nil, "", err
}
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, p, nil
}
return nil, p, err
}
var parsed ArmState
if err := json.Unmarshal(data, &parsed); err != nil {
return nil, p, fmt.Errorf("parse arm state %s: %w", p, err)
}
return &parsed, p, nil
}
// armStateOwnerAlive implements the mandatory liveness check (constraint
// 2), hardened against owner-identity reuse (Codex R1 HIGH-2). A stale
// armed file must never arm a future monitor, so "alive" means the
// recorded owner is not merely present but the SAME owner. The verdict
// itself is OwnerLiveness (session_owner.go), shared with the session
// registry since TASK-2767; this is the consent gate's posture on it:
// ONLY an explicit alive counts. Dead is dead, and unknown — a platform
// that cannot probe the owner — never grants, because a gate that cannot
// verify consent must not grant it (fail closed). It is not dead either:
// SessionArmState keeps an unknown file rather than reaping it, since a
// reap would hand the decision to auto_arm and forget a disarm (BUG-2771).
// The
// registry's pruner takes the opposite posture on the same unknown, and
// that asymmetry is the reason the verdict is tri-state.
//
// The mapping preserves this file's contract exactly: socket-keyed files
// are judged by socket identity (inode+device, else mtime; a file that
// recorded no mtime is dead), headless files by pid plus the recorded
// start token when one was recorded — and a headless file with NO token
// (a non-Linux unix arm, e.g. macOS) falls back to bare pid-liveness, the
// documented residual on the secondary path. On Windows the pid cannot be
// probed at all: the verdict is unknown, which resolves to
// LocalArmUnverifiable (not armed, not reaped).
func armStateOwnerAlive(st *ArmState) bool {
return armStateOwnerVerdict(st) == LivenessAlive
}
// ownerLivenessFn is OwnerLiveness behind a seam, so the unknown verdict —
// which a real probe produces on Windows, and on unix only when /proc or the
// socket cannot be examined — is reachable from a test on any platform
// (BUG-2771). Production never reassigns it.
var ownerLivenessFn = OwnerLiveness
// armStateOwnerVerdict is the tri-state verdict on st's recorded owner.
// SessionArmState needs all three: dead reaps, alive resolves the file, and
// unknown is neither (BUG-2771).
func armStateOwnerVerdict(st *ArmState) Liveness {
if st == nil {
return LivenessDead
}
owner := SessionOwner{
PID: st.PID,
ProcStart: st.ProcStart,
Socket: st.Socket,
SocketMtimeUnixNano: st.SocketMtimeUnixNano,
SocketIno: st.SocketIno,
SocketDev: st.SocketDev,
}
if st.Socket != "" {
// Socket-keyed: the recorded pid is the short-lived `pad session
// arm` command, informational only — it is dead by the time any
// reader looks, and OwnerLiveness requires every recorded signal to
// agree. The socket is the whole owner here.
owner.PID, owner.ProcStart = 0, ""
}
return ownerLivenessFn(&owner)
}
// LocalArmState is the tri-state of THIS session's local arm-state file
// (PLAN-2613 S3): a live explicit ON, a live explicit OFF, or absent (no
// live local override, so the auto_arm config decides).
type LocalArmState int
const (
// LocalArmAbsent: no local override file — resolve auto_arm.
LocalArmAbsent LocalArmState = iota
// LocalArmOn: a live explicit `pad session arm` — announce armed.
LocalArmOn
// LocalArmOff: a live explicit `pad session disarm` — announce NOT
// armed, beating a repo's auto_arm for this session.
LocalArmOff
// LocalArmError: a local-state file EXISTS but can't be read or parsed.
// It resolves to NOT armed and does NOT fall back to auto_arm (Codex R1
// S3 HIGH-2): a corrupt file could be a disarm we can't read, and
// re-arming despite a possible disarm is the consent fail-open this
// gate exists to prevent. Distinct from LocalArmAbsent (no file at
// all), which safely resolves auto_arm.
LocalArmError
// LocalArmUnverifiable: a well-formed file exists but its owner cannot
// be probed here — every headless file on Windows, and on unix a file
// whose /proc entry or socket cannot be examined (BUG-2771). It resolves
// to NOT armed and is NOT reaped. Reaping it used to fall back to
// auto_arm, forgetting an explicit disarm (consent fail-open). The cost
// is that such a file outlives its session: it keeps auto_arm from
// deciding for this key until `pad session reset` removes it.
LocalArmUnverifiable
)
// SessionArmState reads THIS session's local arm-state file and returns
// its tri-state. It applies the mandatory liveness check and reaps a
// dead-owner file, so a crashed session's override — armed OR disarmed —
// can't linger (constraint 2). A file that exists but cannot be read or
// is not well-formed is NOT reaped and resolves to LocalArmError (fail
// closed, see below); every other failure path is LocalArmAbsent, which
// falls back to auto_arm, because this gates consent and must fail to the
// safe, config-decided answer.
//
// This is the reader half of the file contract. The full announced value
// (folding in auto_arm) is ResolveAnnouncedArmed — see cmd/pad's monitor
// wiring.
func SessionArmState() LocalArmState {
st, path, err := readArmState()
if err != nil {
// A file exists but can't be read/parsed. Fail CLOSED and do NOT
// reap: reaping would make the NEXT read see "absent" and fall back
// to auto_arm, re-arming despite a possible-but-unreadable disarm
// (Codex R1 S3 HIGH-2). Leaving it keeps the session consistently
// not-armed until a re-arm/disarm overwrites it; it is session-keyed
// and dies with the session anyway.
return LocalArmError
}
if st == nil {
return LocalArmAbsent
}
if !armStateWellFormed(st) {
// Parsed as JSON but missing the stamps our writer always sets — a
// truncated, hand-crafted, or foreign file (e.g. `{}` or
// `{"pid":1}`). Treat it exactly like a parse error: fail CLOSED and
// do NOT reap, so it can't be judged owner-dead, reaped, and then
// re-armed through auto_arm (Codex R2 S3 HIGH-2). Syntactic validity
// is not well-formedness.
return LocalArmError
}
switch armStateOwnerVerdict(st) {
case LivenessDead:
// Dead owner: reap the stale file so it can't override a future
// session (constraint 2). Absent falls back to auto_arm — which is
// exactly the "across sessions, auto_arm remains the contract"
// ruling for a disarmed file too.
reapArmFile(path)
return LocalArmAbsent
case LivenessUnknown:
// Cannot tell whether the owner is alive. Reaping would treat that
// as dead and hand the decision to auto_arm, forgetting a disarm;
// honouring the file would arm on unverified consent. Neither: keep
// the file, arm nothing (BUG-2771).
return LocalArmUnverifiable
}
if st.Disarmed {
return LocalArmOff
}
return LocalArmOn
}
// VerifyArmStateHeld reads this session's arm state back after an arm or
// disarm write and refuses when the file did not survive the read: its
// owner was already dead, so it was reaped and auto_arm decides as if the
// verb had never run (BUG-3227). That happens when a headless write names
// no session process (no PAD_SESSION_PID or CLAUDE_PID), so the owner is
// the exiting command itself. Reporting success there would state a
// consent change that did not happen, which for a disarm is the fail-open
// this file exists to prevent.
//
// The reap is not always observable here. A headless file owned by "self"
// names THIS command, which is still running during the read-back, so it
// reads as alive and dies the moment the command exits. Such a file is
// refused on its recorded source rather than on its verdict. Where the pid
// cannot be probed at all (Windows) the verdict is unknown instead, and
// the file is kept as LocalArmUnverifiable (BUG-2771), so that case is
// not refused.
func VerifyArmStateHeld() (LocalArmState, error) {
state := SessionArmState()
if state == LocalArmAbsent {
return state, errArmStateNotHeld
}
if state == LocalArmOn || state == LocalArmOff {
if st, _, err := readArmState(); err == nil && st != nil && st.Socket == "" && st.PIDSource == "self" {
return state, errArmStateNotHeld
}
}
return state, nil
}
var errArmStateNotHeld = fmt.Errorf("this session's arm state could not be held: no long-lived session process owns it " +
"(set PAD_SESSION_PID to the session's pid, or use .pad.toml push.auto_arm)")
// armStateWellFormed reports whether a parsed ArmState carries the stamps
// AND invariants writeArmStateFile always produces: a non-empty RFC3339
// StartedAt, a positive PID, and exactly one of Armed/Disarmed set (the
// writer sets Armed == !Disarmed). It is the fail-closed guard against a
// syntactically-valid but semantically-garbage file (Codex R2/R4 S3):
// - `{}` has no StartedAt;
// - `{"pid":1}` has a live-looking pid (init) but no StartedAt;
// - `{"pid":N,"started_at":"…"}` with BOTH armed and disarmed false (or
// both true) is not a state the writer emits, and without the
// Armed != Disarmed check it would resolve to LocalArmOn and arm.
//
// It deliberately does NOT validate the owner-identity stamps (socket
// mtime, ProcStart) — those vary by platform and their absence is the
// documented headless residual, not a corruption signal; the point here is
// only to reject files this code could not have written.
func armStateWellFormed(st *ArmState) bool {
return st != nil && st.StartedAt != "" && st.PID > 0 && st.Armed != st.Disarmed
}
// SessionArmedLocally reports whether this session has a live explicit ARM
// (LocalArmOn). Retained for callers that only ask the ON question; the
// full tri-state, including an explicit OFF that beats auto_arm, is
// SessionArmState.
func SessionArmedLocally() bool {
return SessionArmState() == LocalArmOn
}
// ResolveAnnouncedArmed is the full S3 resolution of what a stream this
// session opens should announce for ?armed (PLAN-2613 S3): a live explicit
// local override wins — ON arms, OFF does not (beating auto_arm for this
// session) — and only in the absence of a live override does the repo's
// auto_arm config decide.
func ResolveAnnouncedArmed() bool {
switch SessionArmState() {
case LocalArmOn:
return true
case LocalArmOff, LocalArmError, LocalArmUnverifiable:
// OFF, ERROR and UNVERIFIABLE all suppress arming: an explicit
// disarm wins, and a local state that cannot be read (HIGH-2) or
// whose owner cannot be verified (BUG-2771) fails closed rather
// than falling to auto_arm.
return false
default: // LocalArmAbsent
return ResolveAutoArmFromDisk().Armed
}
}
// MarkFirstConnect reports whether THIS session's first-connect boot ritual
// should run now, and marks it done so a later call (a reconnect) returns
// false (PLAN-2613 D8). Idempotent per session: the first live call returns
// true, every later one false; the flag dies with the session.
//
// It operates on the arm-state file, which /pad:connect writes before it
// boots (connect arms first). If no live arm-state file exists — an
// unexpected call order — it returns true WITHOUT persisting: skipping a
// boot ritual silently is worse than running it twice (the playbooks are
// meant to be re-runnable), so this fails toward running it.
func MarkFirstConnect() (first bool, err error) {
st, path, rerr := readArmState()
if rerr != nil || st == nil || !armStateOwnerAlive(st) {
return true, nil
}
if st.Booted {
return false, nil
}
st.Booted = true
data, merr := json.MarshalIndent(st, "", " ")
if merr != nil {
return true, fmt.Errorf("marshal arm state: %w", merr)
}
if werr := atomicWriteFile(path, data, 0600); werr != nil {
return true, fmt.Errorf("write arm state: %w", werr)
}
return true, nil
}
// reapArmFile removes a stale arm file, but only after RE-READING it and
// confirming it is still stale — so a concurrent `pad session arm` that
// rewrote a fresh, live file between our first read and now is not
// destroyed (a non-destructive reap; Codex R1 MED-1). A tiny window
// remains between this re-check and the removal, but both outcomes are
// safe: at worst a just-armed session is disarmed and must re-arm (fail
// closed), never the reverse. Best effort throughout — a lingering file
// is simply re-evaluated on the next read.
func reapArmFile(path string) {
if st, _, err := readArmState(); err == nil && st != nil && armStateOwnerVerdict(st) != LivenessDead {
return // re-written by an owner that is not provably dead — leave it
}
_ = os.Remove(path)
}