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
202 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|