Files
pad/internal/cli/client_stream_identity_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

202 lines
6.9 KiB
Go

package cli
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestNewWatchEventsStreamRequest_AnnouncesIdentity covers PLAN-2558
// S2's client half: the session identity has to reach the server as
// request headers, because the presence registry fills from the stream
// connection itself.
func TestNewWatchEventsStreamRequest_AnnouncesIdentity(t *testing.T) {
t.Parallel()
c := NewClientFromURL("http://127.0.0.1:0")
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{
Label: "docapp",
PID: 4242,
})
if err != nil {
t.Fatalf("build request: %v", err)
}
if got := req.Header.Get("X-Pad-Session-Label"); got != "docapp" {
t.Fatalf("label header = %q, want %q", got, "docapp")
}
if got := req.Header.Get("X-Pad-Session-Pid"); got != "4242" {
t.Fatalf("pid header = %q, want %q", got, "4242")
}
// The stream's pre-existing contract must survive the new parameter.
if got := req.Header.Get("Accept"); got != "text/event-stream" {
t.Fatalf("Accept header = %q, want text/event-stream", got)
}
}
// TestNewWatchEventsStreamRequest_OmitsUnsetIdentity pins absence
// rather than emptiness. Sending `X-Pad-Session-Label: ""` would make
// every unannounced client look like a client that announced nothing
// useful — indistinguishable at the server, but noise on the wire and
// an invitation for a future reader to treat "present but empty" as a
// meaningful state.
func TestNewWatchEventsStreamRequest_OmitsUnsetIdentity(t *testing.T) {
t.Parallel()
c := NewClientFromURL("http://127.0.0.1:0")
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{})
if err != nil {
t.Fatalf("build request: %v", err)
}
if _, ok := req.Header["X-Pad-Session-Label"]; ok {
t.Fatal("expected no label header at all for a zero identity")
}
if _, ok := req.Header["X-Pad-Session-Pid"]; ok {
t.Fatal("expected no pid header at all for a zero identity")
}
}
// TestNewWatchEventsStreamRequest_UnsendableLabelStillConnects is the
// regression for the failure Codex round 1 found on this PR, and the
// reason it is asserted by DOING THE ROUND TRIP rather than by
// inspecting the header: the bug was never about the header's contents.
// Unix directory names may contain newlines ("doc\napp" is a legal
// directory), Go's http.Client refuses to SEND a header value holding
// one, and Do returns an error before anything reaches the server. In
// the monitor that looks exactly like an unreachable padd, so its retry
// loop backs off and tries again — forever, printing nothing, with the
// user simply never receiving notifications. The server cannot defend
// against a request that was never transmitted.
//
// A test that only checked the header value would pass against the
// broken version too, since http.Header.Set stores anything; only
// attempting the request distinguishes them.
func TestNewWatchEventsStreamRequest_UnsendableLabelStillConnects(t *testing.T) {
t.Parallel()
var gotLabel string
var sawRequest bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
gotLabel = r.Header.Get("X-Pad-Session-Label")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewClientFromURL(srv.URL)
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{
Label: "doc\napp",
PID: 4242,
})
if err != nil {
t.Fatalf("build request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request was not sendable — the monitor would retry forever and deliver nothing: %v", err)
}
defer resp.Body.Close()
if !sawRequest {
t.Fatal("server never saw the request")
}
if gotLabel != "docapp" {
t.Fatalf("label = %q, want the control byte dropped (%q)", gotLabel, "docapp")
}
}
// TestNewWatchEventsStreamRequest_ArmedSendsQueryParam is PLAN-2613 S2's
// client half: an armed identity must reach the server as ?armed=true (a
// query param, not a header — see StreamSessionIdentity.Armed). Asserted
// by the round trip, matching the label test's stance: the value only
// matters if it actually arrives, and it is what admits the connection to
// push delivery, so a broken wiring is a silent consent failure.
func TestNewWatchEventsStreamRequest_ArmedSendsQueryParam(t *testing.T) {
t.Parallel()
var gotArmed string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotArmed = r.URL.Query().Get("armed")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewClientFromURL(srv.URL)
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{Armed: true})
if err != nil {
t.Fatalf("build request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request not sendable: %v", err)
}
defer resp.Body.Close()
// Exactly "true" — the server treats only that value as armed, so
// anything else would be a silent no-consent.
if gotArmed != "true" {
t.Fatalf("armed query param = %q, want %q", gotArmed, "true")
}
}
// TestNewWatchEventsStreamRequest_UnarmedOmitsQueryParam pins absence, not
// armed=false. Sending armed=false would be indistinguishable at the
// server (only "true" counts) but invites a reader to treat "present but
// false" as meaningful, and it must never look like a partially-armed
// state. A zero identity carries no armed param at all.
func TestNewWatchEventsStreamRequest_UnarmedOmitsQueryParam(t *testing.T) {
t.Parallel()
c := NewClientFromURL("http://127.0.0.1:0")
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{Label: "docapp"})
if err != nil {
t.Fatalf("build request: %v", err)
}
if _, ok := req.URL.Query()["armed"]; ok {
t.Fatalf("expected no armed query param for an unarmed identity, got %q", req.URL.RawQuery)
}
}
// TestHeaderSafeLabel covers the pieces the round trip above can't show
// individually.
func TestHeaderSafeLabel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{"ordinary", "docapp", "docapp"},
{"newline dropped", "doc\napp", "docapp"},
{"carriage return dropped", "doc\rapp", "docapp"},
{"tab dropped", "doc\tapp", "docapp"},
{"spaces survive — legal in a header value", "my project", "my project"},
{"surrounding space trimmed", " docapp ", "docapp"},
{"control-only becomes empty, so no header is sent at all", "\n\t\x00", ""},
{"non-ascii printable survives", "проект", "проект"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := headerSafeLabel(tc.in); got != tc.want {
t.Fatalf("headerSafeLabel(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestHeaderSafeLabel_BoundsLength(t *testing.T) {
t.Parallel()
got := headerSafeLabel(strings.Repeat("é", maxHeaderLabelLen+50))
if n := len([]rune(got)); n != maxHeaderLabelLen {
t.Fatalf("got %d runes, want %d", n, maxHeaderLabelLen)
}
}