mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
a6f96107f4
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.
A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.
- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
The server checks the shape (400 invalid_worker_id) and always matches it
together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
next/ready foreign filter, COALESCEd so its negation is exact. A
worker-bound lease is ours iff worker id + account match; a name-only one
keeps the label + account rule, and a worker refreshing its own name-only
lease binds it. A name-only caller never matches a worker-bound lease,
even under the same label (the collision fixed). Migrations SQLite 142 /
PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
?worker_id query, then the remote MCP caller. The claim answers with the
caller's own worker_id; reads and the 409 show only the SHORT id
("wk_3f2a"); a name-only caller colliding under its own name is told how to
get a worker id.
- CLI: `pad session register` mints the id into the session's registry
record, keeps it when the same process re-registers (a /clear: same pid
and start token) and mints a new one for a new process; older records get
one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
--worker-id on claim, release, project next and ready (the local-stdio MCP
path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
and the MCP session; an explicit `worker_id` (pad_item claim/release,
pad_project next/ready) overrides it and is the resume path across
connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
renders no lease today; the TS type gains the short worker for a future
badge.
Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
172 lines
5.3 KiB
Go
172 lines
5.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// TASK-3613 (lead ruling): a session's lease worker id survives a /clear in
|
|
// the same process (which re-runs `pad session register`) and is new for a
|
|
// new process, including one that reuses the pid.
|
|
|
|
func TestTASK3613_WorkerIDSurvivesReRegisterBySameProcess(t *testing.T) {
|
|
registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
|
|
|
|
first, err := RegisterSession("/w", "rook")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg1, _ := readRegistration(t, first.Path)
|
|
if !models.ValidWorkerID(reg1.WorkerID) {
|
|
t.Fatalf("register did not mint a valid worker id: %q", reg1.WorkerID)
|
|
}
|
|
if reg1.ProcStart == "" {
|
|
t.Skip("this platform records no process start token, so a re-register cannot be told from a new process")
|
|
}
|
|
|
|
// A /clear: the same process registers again.
|
|
second, err := RegisterSession("/w", "rook")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg2, _ := readRegistration(t, second.Path)
|
|
if reg2.WorkerID != reg1.WorkerID {
|
|
t.Fatalf("re-register by the same process changed the worker id: %q -> %q", reg1.WorkerID, reg2.WorkerID)
|
|
}
|
|
if got := ResolveWorkerID(); got != reg1.WorkerID {
|
|
t.Fatalf("ResolveWorkerID = %q, want the registered %q", got, reg1.WorkerID)
|
|
}
|
|
}
|
|
|
|
func TestTASK3613_NewProcessGetsANewWorkerID(t *testing.T) {
|
|
registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
|
|
|
|
first, err := RegisterSession("/w", "rook")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg1, raw := readRegistration(t, first.Path)
|
|
if reg1.ProcStart == "" {
|
|
t.Skip("this platform records no process start token")
|
|
}
|
|
|
|
// The record now belongs to an EARLIER process that had this pid: same
|
|
// pid, different start token. A registration by the live process must
|
|
// not inherit its worker id.
|
|
raw["proc_start"] = "an-earlier-process"
|
|
data, err := json.Marshal(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(first.Path, data, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := RegisterSession("/w", "rook")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg2, _ := readRegistration(t, second.Path)
|
|
if !models.ValidWorkerID(reg2.WorkerID) || reg2.WorkerID == reg1.WorkerID {
|
|
t.Fatalf("a new process inherited the old worker id: %q -> %q", reg1.WorkerID, reg2.WorkerID)
|
|
}
|
|
}
|
|
|
|
// A record written before worker ids existed gets one minted on first use,
|
|
// persisted, and reused.
|
|
func TestTASK3613_LazyMintForAnOlderRecord(t *testing.T) {
|
|
registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
|
|
rec, err := RegisterSession("/w", "rook")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, raw := readRegistration(t, rec.Path)
|
|
delete(raw, "worker_id")
|
|
data, _ := json.Marshal(raw)
|
|
if err := os.WriteFile(rec.Path, data, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := ResolveWorkerID()
|
|
if !models.ValidWorkerID(got) {
|
|
t.Fatalf("no worker id minted for an older record: %q", got)
|
|
}
|
|
reg, _ := readRegistration(t, rec.Path)
|
|
if reg.WorkerID != got {
|
|
t.Fatalf("the minted id was not persisted: record %q, resolved %q", reg.WorkerID, got)
|
|
}
|
|
if again := ResolveWorkerID(); again != got {
|
|
t.Fatalf("a second resolve minted again: %q -> %q", got, again)
|
|
}
|
|
}
|
|
|
|
func TestTASK3613_WorkerIDOverrideAndUnregistered(t *testing.T) {
|
|
registryEnv(t)
|
|
t.Setenv("CLAUDE_PID", itoa(os.Getppid()))
|
|
|
|
// Unregistered: name-only, and nothing is written.
|
|
if got := ResolveWorkerID(); got != "" {
|
|
t.Fatalf("an unregistered session resolved worker id %q, want none", got)
|
|
}
|
|
|
|
const explicit = "wk_0123456789abcdef0123456789abcdef"
|
|
t.Setenv("PAD_WORKER_ID", explicit)
|
|
if got := ResolveWorkerID(); got != explicit {
|
|
t.Fatalf("PAD_WORKER_ID = %q not honoured: %q", explicit, got)
|
|
}
|
|
t.Setenv("PAD_WORKER_ID", "not-a-worker-id")
|
|
if got := ResolveWorkerID(); got != "" {
|
|
t.Fatalf("a malformed PAD_WORKER_ID was used: %q", got)
|
|
}
|
|
|
|
c := &Client{}
|
|
if err := c.SetWorkerID("wk_short"); err == nil {
|
|
t.Fatal("SetWorkerID accepted a malformed id")
|
|
}
|
|
if err := c.SetWorkerID(explicit); err != nil || c.workerID != explicit {
|
|
t.Fatalf("SetWorkerID: err %v, id %q", err, c.workerID)
|
|
}
|
|
}
|
|
|
|
// keptWorkerID directly, including platforms that record no start token,
|
|
// where the TestTASK3613 registry tests above skip: with no token on either
|
|
// side the pid alone keeps the id (a /clear must keep its leases); a token
|
|
// on one side only is a different process.
|
|
func TestTASK3613_KeptWorkerIDStartTokenRules(t *testing.T) {
|
|
const id = "wk_0123456789abcdef0123456789abcdef"
|
|
path := t.TempDir() + "/1234.json"
|
|
write := func(procStart string) {
|
|
t.Helper()
|
|
reg := map[string]any{"session_pid": 1234, "pid": 1, "cwd": "/w", "registered_at": "2026-10-12T00:00:00Z", "worker_id": id, "version": 2}
|
|
if procStart != "" {
|
|
reg["proc_start"] = procStart
|
|
}
|
|
data, _ := json.Marshal(reg)
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cases := []struct {
|
|
name, recorded, current string
|
|
keep bool
|
|
}{
|
|
{"same token", "100", "100", true},
|
|
{"other token (pid reuse)", "100", "200", false},
|
|
{"no token either side", "", "", true},
|
|
{"token on the record only", "100", "", false},
|
|
{"token on the owner only", "", "200", false},
|
|
}
|
|
for _, c := range cases {
|
|
write(c.recorded)
|
|
got := keptWorkerID(path, SessionOwner{PID: 1234, ProcStart: c.current})
|
|
if (got == id) != c.keep {
|
|
t.Errorf("%s: kept = %q, want keep=%v", c.name, got, c.keep)
|
|
}
|
|
}
|
|
}
|