Files
pad/internal/cli/arm_consent_test.go
xarmian e40df6b31c feat(cli): pad session arm/disarm/status + consent config resolution (PLAN-2613 S2, TASK-2617) (#1149)
* 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
2026-08-17 22:44:31 -04:00

151 lines
4.7 KiB
Go

package cli
import (
"os"
"path/filepath"
"testing"
)
func boolPtr(b bool) *bool { return &b }
// TestResolveAutoArm is the full consent truth table (PLAN-2613 S2, D4).
// Every row is a distinct assertion so a mutation that flips one cell —
// dropping the veto, making a per-user true an enabler, defaulting on —
// fails on exactly that row and names it.
func TestResolveAutoArm(t *testing.T) {
t.Parallel()
tests := []struct {
name string
repoAutoArm bool
userAutoArm *bool
want bool
}{
{"default: neither set", false, nil, false},
{"per-user true is NOT an enabler (no machine-global always-on)", false, boolPtr(true), false},
{"per-user false with no repo opt-in stays off", false, boolPtr(false), false},
{"repo opt-in, user no opinion", true, nil, true},
{"repo opt-in, user also true", true, boolPtr(true), true},
{"repo opt-in VETOED by per-user false (deny-wins)", true, boolPtr(false), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := ResolveAutoArm(tc.repoAutoArm, tc.userAutoArm); got != tc.want {
t.Fatalf("ResolveAutoArm(%v, %v) = %v, want %v", tc.repoAutoArm, tc.userAutoArm, got, tc.want)
}
})
}
}
// TestResolveAutoArmFromDisk_ReadsBothSources wires the resolver to the
// two real config files: a repo .pad.toml and the per-user config.toml.
// It exercises the enable path and the veto path end to end, so a
// regression in either loader (wrong toml key, wrong nil-handling) shows
// up here and not just in the pure resolver.
func TestResolveAutoArmFromDisk_ReadsBothSources(t *testing.T) {
// Not parallel: mutates HOME and the working directory.
tests := []struct {
name string
padTomlBody string
configBody string
wantArmed bool
wantRepo bool
wantVeto bool
}{
{
name: "repo opt-in, no user config → armed",
padTomlBody: "workspace = \"demo\"\n[push]\nauto_arm = true\n",
configBody: "",
wantArmed: true,
wantRepo: true,
wantVeto: false,
},
{
name: "repo opt-in vetoed by per-user false → not armed",
padTomlBody: "workspace = \"demo\"\n[push]\nauto_arm = true\n",
configBody: "[push]\nauto_arm = false\n",
wantArmed: false,
wantRepo: true,
wantVeto: true,
},
{
name: "no [push] table anywhere → default off",
padTomlBody: "workspace = \"demo\"\n",
configBody: "",
wantArmed: false,
wantRepo: false,
wantVeto: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// config.Load keys the user config off PAD_DATA_DIR when set;
// point it at HOME/.pad so the per-user config.toml is found.
dataDir := filepath.Join(home, ".pad")
if err := os.MkdirAll(dataDir, 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PAD_DATA_DIR", dataDir)
if tc.configBody != "" {
if err := os.WriteFile(filepath.Join(dataDir, "config.toml"), []byte(tc.configBody), 0600); err != nil {
t.Fatal(err)
}
}
repoDir := t.TempDir()
if err := os.WriteFile(filepath.Join(repoDir, ".pad.toml"), []byte(tc.padTomlBody), 0600); err != nil {
t.Fatal(err)
}
chdir(t, repoDir)
got := ResolveAutoArmFromDisk()
if got.Armed != tc.wantArmed {
t.Errorf("Armed = %v, want %v", got.Armed, tc.wantArmed)
}
if got.RepoAutoArm != tc.wantRepo {
t.Errorf("RepoAutoArm = %v, want %v", got.RepoAutoArm, tc.wantRepo)
}
if got.UserVeto != tc.wantVeto {
t.Errorf("UserVeto = %v, want %v", got.UserVeto, tc.wantVeto)
}
})
}
}
// TestResolveAutoArmFromDisk_UnreadableConfigFailsClosed is the Codex R1
// HIGH-1 regression: a repo that opted into auto_arm must NOT arm when the
// per-user config.toml exists but can't be parsed — a veto we can't read
// must be assumed present (fail closed), and the decision must say why.
func TestResolveAutoArmFromDisk_UnreadableConfigFailsClosed(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dataDir := filepath.Join(home, ".pad")
if err := os.MkdirAll(dataDir, 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PAD_DATA_DIR", dataDir)
// A config.toml that exists but is not valid TOML.
if err := os.WriteFile(filepath.Join(dataDir, "config.toml"), []byte("this is [not valid"), 0600); err != nil {
t.Fatal(err)
}
repoDir := t.TempDir()
if err := os.WriteFile(filepath.Join(repoDir, ".pad.toml"), []byte("workspace = \"demo\"\n[push]\nauto_arm = true\n"), 0600); err != nil {
t.Fatal(err)
}
chdir(t, repoDir)
got := ResolveAutoArmFromDisk()
if got.Armed {
t.Fatal("repo opt-in + unreadable user config must fail closed (not armed)")
}
if !got.ConfigUnreadable {
t.Fatal("ConfigUnreadable must be set so status can explain the off state")
}
}