mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05: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
151 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|