mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
02846a6785
* fix(cli): the session's registered agent is the name its writes carry (BUG-2882) Two seats booted under one name; one re-registered under the right one with `pad session register --agent`; every write it made afterwards still carried the wrong name. The registry row and $PAD_AGENT were two self-declarations of "the same value" — the row could be rewritten, the environment could not, and nothing reconciled them or warned. Night 10 read both live seats inverted against each other. ResolveAgentName now consults the registry record for the session that owns this process FIRST: a stat-and-read of one file, no MkdirAll, no lock, ignored when malformed, legacy, or carrying a different process-start token (pid reuse). A non-empty registered name wins over .pad.toml and $PAD_AGENT; an anonymous row leaves an environment name in force. `pad session register` without --agent keeps the current name, as before, because its default is the resolver. Help text, the record's doc comment and README's precedence list say what is now true. Test: register as rook under PAD_AGENT=wren and a .pad.toml name → rook; default re-register → rook; anonymous → the .pad.toml name; a record for this pid with another process's start token → ignored. The registry-step mutant fails the first two assertions. Fixes BUG-2882 * fix(cli): a registry record names this session only when it is verifiably this session's, and the identity tests stop reading the real registry Codex round 1 on #1248. (1) registeredAgentForThisSession compared process-start tokens only when both sides had one, so a stale row with no token under a reused pid — a dead session's — would have named a live one. Fail closed: when this process can read a token, the record must carry the same one; and the record must pass the same OwnerLiveness verdict `pad session list` applies. The token-less case is now a test row, with a positive control after it. (2) The pre-existing resolver and header tests cleared PAD_AGENT/CLAUDECODE but not HOME or the session-pid variables, so run inside a registered seat they would have read that seat's row as the resolver's first answer. They now run from a scratch HOME with no session identity. Refs BUG-2882 * fix(cli): a registry row names this session only if its owner is this process or an ancestor, where that can be checked Codex round 2 on #1248. (1) TestPushItemSendsResolvedAgentHeader was the one identity test round 1's hermeticity fix missed; it now isolates HOME and the session env like its neighbours. (2) The registry step accepted a row whose owner pid was alive and token-matched but NOT this process or an ancestor — a misconfigured CLAUDE_PID pointing at a sibling session could borrow that session's name. Refused where the platform can walk ancestry. Not gated on PIDVerified: CaptureSessionOwner records "cannot check" and "checked and wrong" as the same false, and the flag alone would have disabled the step on every non-Linux platform. The check is re-run and only a checked-and-wrong answer refuses. Test uses a live non-ancestor child as the claimed owner; the refusal-dropped mutant lets it name us. Refs BUG-2882 * docs(cli): session list help — a row's name outranks PAD_AGENT; change it with --agent, not by re-registering (BUG-2882, codex round 3)
347 lines
12 KiB
Go
347 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// BUG-2542. X-Pad-Agent is the only signal the server has for attributing a
|
|
// write to an agent, and it used to be populated from .pad.toml's agent_name
|
|
// and nothing else — so an agent session in a workspace that had not opted in
|
|
// was recorded as the human whose credentials it used.
|
|
//
|
|
// Each case pins one rung of the precedence AND the negative that makes it
|
|
// meaningful: a plain human shell with no markers must still resolve to "",
|
|
// because a resolver that returned a name unconditionally would satisfy every
|
|
// positive case here while attributing Dave's own commands to an agent.
|
|
func TestResolveAgentName(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
padToml string
|
|
env map[string]string
|
|
want string
|
|
}{
|
|
{
|
|
name: "plain human shell resolves to nothing",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "detected runtime is used when nothing more explicit exists",
|
|
env: map[string]string{"CLAUDECODE": "1"},
|
|
want: "claude-code",
|
|
},
|
|
{
|
|
name: "PAD_AGENT overrides a detected runtime",
|
|
env: map[string]string{"CLAUDECODE": "1", "PAD_AGENT": "some-other-harness"},
|
|
want: "some-other-harness",
|
|
},
|
|
{
|
|
name: "pad.toml wins over both",
|
|
padToml: "workspace = \"w\"\nagent_name = \"wren\"\n",
|
|
env: map[string]string{"CLAUDECODE": "1", "PAD_AGENT": "some-other-harness"},
|
|
want: "wren",
|
|
},
|
|
{
|
|
name: "an empty marker is not a marker",
|
|
env: map[string]string{"CLAUDECODE": "", "PAD_AGENT": ""},
|
|
want: "",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Run from a scratch cwd so a .pad.toml higher up the real tree
|
|
// (this repo has one) cannot leak into the no-toml cases.
|
|
dir := t.TempDir()
|
|
if tc.padToml != "" {
|
|
if err := os.WriteFile(filepath.Join(dir, ".pad.toml"), []byte(tc.padToml), 0o600); err != nil {
|
|
t.Fatalf("write .pad.toml: %v", err)
|
|
}
|
|
}
|
|
chdir(t, dir)
|
|
// And from a scratch HOME with no session identity, so a registry
|
|
// row belonging to the REAL session running these tests cannot
|
|
// leak in as the resolver's first answer (codex round 1, #1248).
|
|
isolateHome(t, t.TempDir())
|
|
clearSessionEnv(t)
|
|
|
|
// Clear every variable the resolver consults, then set this case's.
|
|
for _, k := range []string{"PAD_AGENT", "CLAUDECODE"} {
|
|
t.Setenv(k, "")
|
|
os.Unsetenv(k)
|
|
}
|
|
for k, v := range tc.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
|
|
if got := ResolveAgentName(); got != tc.want {
|
|
t.Errorf("ResolveAgentName() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func chdir(t *testing.T, dir string) {
|
|
t.Helper()
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatalf("chdir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(prev) })
|
|
}
|
|
|
|
// The resolver is only half the path: its value has to reach the wire as
|
|
// X-Pad-Agent, which is the header the server keys attribution on. The
|
|
// server-side tests inject that header directly, so without this the
|
|
// resolver could be correct and the client still send nothing (which is
|
|
// exactly the pre-BUG-2542 state).
|
|
func TestClientSendsResolvedAgentHeader(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
env map[string]string
|
|
want string
|
|
}{
|
|
{name: "agent session sends the header", env: map[string]string{"CLAUDECODE": "1"}, want: "claude-code"},
|
|
{name: "human shell sends none", env: nil, want: ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
chdir(t, t.TempDir())
|
|
isolateHome(t, t.TempDir())
|
|
clearSessionEnv(t)
|
|
for _, k := range []string{"PAD_AGENT", "CLAUDECODE"} {
|
|
t.Setenv(k, "")
|
|
os.Unsetenv(k)
|
|
}
|
|
for k, v := range tc.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
|
|
var got string
|
|
var seen bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got, seen = r.Header.Get("X-Pad-Agent"), true
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if err := NewClientFromURL(srv.URL).Health(); err != nil {
|
|
t.Fatalf("health: %v", err)
|
|
}
|
|
if !seen {
|
|
t.Fatal("server never saw the request")
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("X-Pad-Agent = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPushItemSendsResolvedAgentHeader (IDEA-2544 Phase 1, dispatcher
|
|
// review) pins that PushItem inherits X-Pad-Agent the same as every
|
|
// other mutating client method, rather than assuming: PushItem doesn't
|
|
// build its own request, it goes through c.post -> c.newRequest like
|
|
// CreateWatch and everything else, so the BUG-2542 fix upgraded it for
|
|
// free — but "the code path implies it" and "verified against a live
|
|
// request" are different claims, and only the second belongs in a
|
|
// report. Also confirms the server side of the loop: handlePushToItem's
|
|
// actor/actorName come from actorFromRequest/actorNameFromRequest,
|
|
// which read this exact header.
|
|
func TestPushItemSendsResolvedAgentHeader(t *testing.T) {
|
|
chdir(t, t.TempDir())
|
|
isolateHome(t, t.TempDir())
|
|
clearSessionEnv(t)
|
|
for _, k := range []string{"PAD_AGENT", "CLAUDECODE"} {
|
|
t.Setenv(k, "")
|
|
os.Unsetenv(k)
|
|
}
|
|
t.Setenv("CLAUDECODE", "1")
|
|
|
|
var got string
|
|
var seen bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got, seen = r.Header.Get("X-Pad-Agent"), true
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-1","pushed":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := NewClientFromURL(srv.URL).PushItem("demo", "TASK-1", "triage this"); err != nil {
|
|
t.Fatalf("PushItem: %v", err)
|
|
}
|
|
if !seen {
|
|
t.Fatal("server never saw the request")
|
|
}
|
|
if got != "claude-code" {
|
|
t.Errorf("X-Pad-Agent = %q, want %q", got, "claude-code")
|
|
}
|
|
}
|
|
|
|
// ActorKind backs the one place the client must self-describe: structured
|
|
// entries inside an item's fields JSON, which the server never parses. An
|
|
// earlier revision of the BUG-2542 fix removed the CLI's hardcoded "user"
|
|
// there on the theory that the server would stamp it — it does not, and the
|
|
// entries would have been written authorless.
|
|
func TestActorKind(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
env map[string]string
|
|
want string
|
|
}{
|
|
{name: "agent session", env: map[string]string{"CLAUDECODE": "1"}, want: "agent"},
|
|
{name: "explicit PAD_AGENT", env: map[string]string{"PAD_AGENT": "some-harness"}, want: "agent"},
|
|
{name: "human shell", env: nil, want: "user"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
chdir(t, t.TempDir())
|
|
isolateHome(t, t.TempDir())
|
|
clearSessionEnv(t)
|
|
for _, k := range []string{"PAD_AGENT", "CLAUDECODE"} {
|
|
t.Setenv(k, "")
|
|
os.Unsetenv(k)
|
|
}
|
|
for k, v := range tc.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
if got := ActorKind(); got != tc.want {
|
|
t.Errorf("ActorKind() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegisteredAgentWinsForThisSession — BUG-2882. Two seats booted under one
|
|
// name; one re-registered under the right one with --agent; every write it
|
|
// made afterwards still carried the wrong name, because the registry row and
|
|
// $PAD_AGENT were two self-declarations and nothing reconciled them. The row
|
|
// is now the first thing the resolver reads, so `--agent` means what its help
|
|
// text says. Three properties, each its own case so a regression names
|
|
// itself: the row wins over the environment AND over .pad.toml; an anonymous
|
|
// row does not blank an environment name; a row for a different session that
|
|
// reused this pid is ignored.
|
|
//
|
|
// MUTANT: removing the registry step from ResolveAgentName fails the first
|
|
// two; dropping the proc-start comparison fails the last.
|
|
func TestRegisteredAgentWinsForThisSession(t *testing.T) {
|
|
sessionsDir := registryEnv(t)
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, ".pad.toml"), []byte("workspace = \"w\"\nagent_name = \"toml-name\"\n"), 0o600); err != nil {
|
|
t.Fatalf("write .pad.toml: %v", err)
|
|
}
|
|
chdir(t, dir)
|
|
t.Setenv("PAD_AGENT", "wren")
|
|
// This process is its own session owner (no PAD_SESSION_PID / CLAUDE_PID).
|
|
|
|
if got := ResolveAgentName(); got != "toml-name" {
|
|
t.Fatalf("before any registration, ResolveAgentName() = %q, want the .pad.toml name", got)
|
|
}
|
|
|
|
if _, err := RegisterSession(dir, "rook"); err != nil {
|
|
t.Fatalf("RegisterSession: %v", err)
|
|
}
|
|
if got := ResolveAgentName(); got != "rook" {
|
|
t.Errorf("after registering as rook, ResolveAgentName() = %q, want %q (the row must win over .pad.toml and $PAD_AGENT)", got, "rook")
|
|
}
|
|
|
|
// Re-registering with the default keeps the name: the default IS the
|
|
// resolver, which now reads the row.
|
|
if _, err := RegisterSession(dir, ResolveAgentName()); err != nil {
|
|
t.Fatalf("re-register: %v", err)
|
|
}
|
|
if got := ResolveAgentName(); got != "rook" {
|
|
t.Errorf("after a default re-register, ResolveAgentName() = %q, want %q", got, "rook")
|
|
}
|
|
|
|
// An anonymous row is a statement about the row, not about the writes.
|
|
if _, err := RegisterSession(dir, ""); err != nil {
|
|
t.Fatalf("anonymous register: %v", err)
|
|
}
|
|
if got := ResolveAgentName(); got != "toml-name" {
|
|
t.Errorf("after an anonymous registration, ResolveAgentName() = %q, want the .pad.toml name back", got)
|
|
}
|
|
|
|
// A record for THIS pid but a different process start is another
|
|
// session that once had the pid; it must not name us.
|
|
path := filepath.Join(sessionsDir, itoa(os.Getpid())+".json")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read record: %v", err)
|
|
}
|
|
var reg SessionRegistration
|
|
if err := json.Unmarshal(data, ®); err != nil {
|
|
t.Fatalf("unmarshal record: %v", err)
|
|
}
|
|
if reg.ProcStart == "" {
|
|
t.Skip("platform records no process-start token; the pid-reuse guard has nothing to compare")
|
|
}
|
|
token := reg.ProcStart
|
|
reg.Agent = "ghost"
|
|
reg.ProcStart = token + "-not-us"
|
|
out, _ := json.Marshal(reg)
|
|
if err := os.WriteFile(path, out, 0o600); err != nil {
|
|
t.Fatalf("rewrite record: %v", err)
|
|
}
|
|
if got := ResolveAgentName(); got != "toml-name" {
|
|
t.Errorf("a record from a different session with our pid named us: ResolveAgentName() = %q", got)
|
|
}
|
|
|
|
// A record with NO token, while this process can read one, is
|
|
// unverifiable and must not name us either (codex round 1, #1248: a
|
|
// dead session's stale row under a reused pid).
|
|
reg.ProcStart = ""
|
|
out, _ = json.Marshal(reg)
|
|
if err := os.WriteFile(path, out, 0o600); err != nil {
|
|
t.Fatalf("rewrite record: %v", err)
|
|
}
|
|
if got := ResolveAgentName(); got != "toml-name" {
|
|
t.Errorf("a token-less record for our pid named us: ResolveAgentName() = %q", got)
|
|
}
|
|
|
|
// Positive control after the two rejections: the genuine token names us.
|
|
reg.ProcStart = token
|
|
out, _ = json.Marshal(reg)
|
|
if err := os.WriteFile(path, out, 0o600); err != nil {
|
|
t.Fatalf("rewrite record: %v", err)
|
|
}
|
|
if got := ResolveAgentName(); got != "ghost" {
|
|
t.Errorf("the genuine record stopped naming us: ResolveAgentName() = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestRegisteredAgentRequiresAVerifiableOwner — codex round 2 on #1248. A
|
|
// harness-supplied owner pid that the platform CAN check and that is NOT
|
|
// this process or an ancestor is a misconfigured session: its registry row
|
|
// must not name us, even though the pid is alive and its token matches
|
|
// (pid-reuse and liveness are not the failure here; the claim of ownership
|
|
// is). Where the platform cannot walk ancestry the check does not run and
|
|
// the token + liveness residual applies, which is why this is gated on the
|
|
// check being available rather than on PIDVerified alone — gating on the
|
|
// flag would have switched the fix off everywhere but Linux.
|
|
//
|
|
// MUTANT: dropping the pidIsSelfOrAncestor refusal makes the child's row
|
|
// name us.
|
|
func TestRegisteredAgentRequiresAVerifiableOwner(t *testing.T) {
|
|
registryEnv(t)
|
|
if _, err := pidIsSelfOrAncestor(os.Getpid()); err != nil {
|
|
t.Skip("platform cannot walk process ancestry; the ownership check does not run here")
|
|
}
|
|
dir := t.TempDir()
|
|
chdir(t, dir)
|
|
t.Setenv("PAD_AGENT", "env-name")
|
|
child := livingChildPID(t)
|
|
t.Setenv("CLAUDE_PID", itoa(child))
|
|
|
|
if _, err := RegisterSession(dir, "impostor"); err != nil {
|
|
t.Fatalf("RegisterSession: %v", err)
|
|
}
|
|
if got := ResolveAgentName(); got != "env-name" {
|
|
t.Errorf("a row owned by a live non-ancestor pid named us: ResolveAgentName() = %q, want %q", got, "env-name")
|
|
}
|
|
}
|