mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +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
117 lines
3.7 KiB
Go
117 lines
3.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// ResolveWorkerID decides what goes in the X-Pad-Worker header (TASK-3613):
|
|
// the opaque id this session holds item leases under, so two sessions that
|
|
// share an agent name and an account do not share a lease.
|
|
//
|
|
// 1. $PAD_WORKER_ID when it is well formed: an explicit choice, for resuming
|
|
// after a restart or for a harness pad cannot register.
|
|
// 2. This session's registry record (`pad session register`), minting and
|
|
// storing an id into a record written before worker ids existed.
|
|
// 3. Otherwise "": a name-only caller, today's behaviour. No other
|
|
// persistence is invented (a per-cwd file would be shared by every
|
|
// unregistered session in that checkout, which is the collision again).
|
|
func ResolveWorkerID() string {
|
|
if v := strings.TrimSpace(os.Getenv("PAD_WORKER_ID")); models.ValidWorkerID(v) {
|
|
return v
|
|
}
|
|
return sessionWorkerID()
|
|
}
|
|
|
|
// MintWorkerID returns a fresh worker id: "wk_" + 128 random bits in hex.
|
|
func MintWorkerID() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", fmt.Errorf("mint lease worker id: %w", err)
|
|
}
|
|
return "wk_" + hex.EncodeToString(b[:]), nil
|
|
}
|
|
|
|
// sessionWorkerID returns this session's worker id from its registry record,
|
|
// minting one into a record that predates worker ids. "" when the session is
|
|
// not registered or the record cannot be updated.
|
|
func sessionWorkerID() string {
|
|
path, reg, ok := thisSessionRegistration()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if models.ValidWorkerID(reg.WorkerID) {
|
|
return reg.WorkerID
|
|
}
|
|
unlock, err := lockSessionsDir(filepath.Dir(path))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer unlock()
|
|
// Re-read under the lock: another `pad` process in this session may
|
|
// have minted one since our first read.
|
|
if id := keptWorkerID(path, reg.SessionOwner); id != "" {
|
|
return id
|
|
}
|
|
id, err := MintWorkerID()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
reg.WorkerID = id
|
|
data, err := json.MarshalIndent(reg, "", " ")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if err := atomicWriteFile(path, data, 0600); err != nil {
|
|
return ""
|
|
}
|
|
return id
|
|
}
|
|
|
|
// keptWorkerID is the worker id in the record at path when that record
|
|
// belongs to the same process as owner (pid AND start token), else "". The
|
|
// start token is what tells a /clear in one process from a new process
|
|
// that reuses the pid; see below for platforms that record none.
|
|
func keptWorkerID(path string, owner SessionOwner) string {
|
|
data, err := readRegistryBytes(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var prev SessionRegistration
|
|
if json.Unmarshal(data, &prev) != nil || !registrationWellFormed(&prev) {
|
|
return ""
|
|
}
|
|
// Same pid AND same start token is the same process. Where the platform
|
|
// records no start token on either side, the pid alone has to do: a
|
|
// /clear must keep its leases (lead ruling), and a reused pid inheriting
|
|
// a dead session's id is the lesser risk, since its leases expire within
|
|
// their TTL. A token on one side only is a different process.
|
|
if prev.PID != owner.PID || prev.ProcStart != owner.ProcStart {
|
|
return ""
|
|
}
|
|
if !models.ValidWorkerID(prev.WorkerID) {
|
|
return ""
|
|
}
|
|
return prev.WorkerID
|
|
}
|
|
|
|
// SetWorkerID makes this client send id as X-Pad-Worker instead of the one
|
|
// ResolveWorkerID would pick: the `--worker-id` flag, which is also how a
|
|
// local-stdio MCP call's `worker_id` param arrives. It must be called before
|
|
// the first request. A malformed id is refused here, not sent.
|
|
func (c *Client) SetWorkerID(id string) error {
|
|
if !models.ValidWorkerID(id) {
|
|
return fmt.Errorf(`--worker-id must be "wk_" followed by 32 lowercase hex characters, got %q`, id)
|
|
}
|
|
c.workerOnce.Do(func() {})
|
|
c.workerID = id
|
|
return nil
|
|
}
|