mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-11-02 17:33:47 +00:00
codex/audit-log-redaction
495 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
786406e569 |
fix(cli): print the op_key of a failed write; stop claiming a re-run is safe (BUG-3999) (#2346)
* fix(cli): print the op_key of a failed write; stop claiming a re-run is safe (BUG-3999) Each CLI invocation sends a new idempotency key, so re-running a command after an unclear outcome is a new request. Failed writes that sent an op_key (create, comment, update/note/decide, patch, undo, push, copy) now wrap the error in cli.OpKeyError, and the root error writer prints the key on stderr with the --op-key re-run hint. Chat is excluded (caller-owned key). SKILL.md no longer says a re-run never applies a patch twice. Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * fix(cli): read APIError through the op-key wrapper at every call site (BUG-3999) A write that sent an op_key comes back wrapped in OpKeyError, which a direct err.(*cli.APIError) assertion does not see through: pad item create lost its plan-limit structured marker. Add cli.AsAPIError (errors.As) and use it at all 17 command-side sites. Add the plan-limit, --format json, decide and push tests. Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * fix(cli): keep the op-key hint when create replaces a plan-limit refusal (BUG-3999) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * fix(skill): drop the false retry claim from the plugin's SKILL.md copy too (BUG-3999) plugin/skills/pad/SKILL.md is maintained separately from the embedded skills/pad/SKILL.md (it is not synced from it), and it still said a plain re-run "never applies it twice". It now carries the same corrected text, and the prose pin checks both files. Adds a test that a failed write which sent no op_key prints no hint (r1 H1, H2). Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v |
||
|
|
a8af168304 |
fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090) (#2328)
* fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090) The session registry's verdict probed the owner pid with kill(pid, 0), and a pid means something only inside one PID namespace. From a sandbox or container shell (bwrap --unshare-pid) every host pid is ESRCH, so `pad session list` read every live host session dead, and `pad session prune` - and the prune that follows every `pad session register` - deleted their records. Records now carry pid_ns, the namespace the owner pid was captured in, stamped only when that pid is signalable there. The reader compares: across a boundary the pid is not probed and a missing socket is not evidence, so the verdict is unknown (kept by prune, untrusted by the arm gate). Only a socket answering with a different identity stays dead. A row without pid_ns read from a non-init namespace keeps a positive pid probe and turns a negative one into unknown. Headless arm-state files carry pid_ns too. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): judge a session's socket by its mount namespace (BUG-4090 r1) The socket half of the liveness verdict now keys on the mount namespace the socket was seen in (mnt_ns, stamped only beside a socket stat'ed with its identity). A reader with another filesystem view gets unknown for a missing or different node; a row without mnt_ns keeps the old rule, so a container reading its own rows (or the Nix sandbox) behaves as before. The unverifiable prose now says "from here (this platform, or another namespace)", and the bwrap test gains a leg for a pre-fix, unstamped row. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): a foreign-namespace reader never manufactures dead; chat direction stamps mnt_ns (BUG-4090 r2) An unstamped row read from a non-init PID namespace now reads alive only on a start-token match: a mismatch, a vanished pid or a zombie at that number describes the reader's namespace, not the owner's, and is unknown. The chat-direction file, the third socket-identity writer, records its socket's mount namespace like the registry and the arm file. The help and CLAUDE.md no longer overclaim for rows written before this version. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): an unidentifiable namespace is a possible boundary on Linux (BUG-4090 r3) A reader that cannot read /proc/self/ns/{pid,mnt} (a sandbox with no procfs) no longer treats an unstamped row as same-namespace: its ESRCH or missing socket is unknown, not dead. A Linux writer in that position stamps pid_ns/mnt_ns with the sentinel 1, which no reader's namespace matches, so every reader treats the row as crossed. Off Linux, where namespaces do not exist, nothing changes. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * fix(cli): an empty start token is not a match for a foreign-namespace reader (BUG-4090 r4) An unstamped row with no proc_start, read from a non-init or unreadable PID namespace, read alive whenever any process answered at its number there. That can honour a headless arm or disarm on an unrelated process. With nothing to match, such a reader now says unknown; the bare-probe residual stays for same-namespace and init readers. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC * test: namespace-correct session fixtures for runs outside the init pid namespace (BUG-4090) The Nix check runs go test in its build sandbox's PID namespace, where an unstamped registry row is judged by BUG-4090's foreign-reader rule. Three cmd/pad tests hand-wrote such rows and expected init-namespace verdicts. The two dead-pid fixtures are now stamped with the test's own pid_ns, so they mean "same namespace, dead pid" anywhere; the v1 legacy-row table assertion follows the test's namespace. internal/cli gains a seam test that drives the legacy row's verdict through init, non-init and unreadable readers on every runner. Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC |
||
|
|
b9b0aa784b |
test(cmd/pad): restore the root flag globals after tests that parse them (BUG-4086) (#2320)
workspaceFlag, formatFlag and urlFlag are bound to the root command's
persistent flags, so a test that parses --url/--format/--workspace
in-process writes process state that every later test reads. urlFlag
outranks PAD_URL, so a leaked URL sent a later test's CLI to an earlier
test's closed stub server. Default order hid it: building any root
re-binds the flags and zeroes the globals. Under go test -shuffle, 11 of
12 random seeds failed at
|
||
|
|
ccb4ed4a22 |
test(watch): run the identity-change monitor test on synctest's virtual clock (TASK-4067) (#2316)
* test(watch): run the identity-change monitor test on synctest's virtual clock (TASK-4067) TestMonitorChatIdentityChangeResetsMemory drove runWatchMonitor over a real loopback httptest.Server and paid real reconnect and identity backoffs (32s). It now runs in a synctest bubble over an in-memory net.Pipe listener, with http.DefaultTransport swapped for the bubble's life, and asserts exact virtual elapsed time per case. Every existing assertion is unchanged; the handler's 1ms ack poll is replaced by a channel so the times are exact. TestMonitorChatServerFloorHunterLocalDelivered pins both sides of the first-contact floor max(server floor, local delivered). Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV * test(watch): state and enforce the DefaultTransport swap's no-parallel precondition (TASK-4067 r1) Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV |
||
|
|
6402eb3055 |
fix(mcp): stdio's item patch gives line_hint remote's rule through one shared helper (BUG-4082) (#2315)
* fix(mcp): stdio's item patch gives line_hint remote's rule through one shared helper (BUG-4082)
stdio's pad_item patch was passThrough, so edits reached the CLI unread:
its JSON decode into bodypatch.Edit.LineHint (an int) refused a string
hint that remote coerces ("5"), and took a JSON number of 2^53 or more,
which mcp-go's float64 decode may already have rounded, as a line number.
Remote has validated each edit's line_hint since BUG-4065, inline in
mapItemPatch.
That loop is now editsWithLineHints, called unchanged by mapItemPatch and
by a new stdio action, actionItemPatch. On stdio:
- a valid hint is forwarded as its int64;
- a refusal is validation_failed with remote's message, naming the edit
("(in edits[1])");
- null is absent, and a non-object edit keeps its path.
Neither transport modifies the caller's map.
Tests (lead ruling 86931478): one table, bug4082Cases, runs every input
through both transports and requires the same forwarded edits or the same
refusal (C1). Remote's BUG-4065 rows stay green unchanged. A row on each
transport pins the caller's map (C2). Red on main: stdio forwards "010",
1.5, 2^53, true and {} raw while every remote half passes.
ToolSurfaceVersion 0.105 -> 0.106 (C3), with the changelog in version.go
and docs/mcp.md; the README and the instructions.md heading follow. The
wire goldens follow in the next commit.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* test(mcp): stdio's line_hint refusal checked on its own; actionItemPatch's input pinned directly (BUG-4082)
Mutants on
|
||
|
|
729ae5f69c |
fix(mcp): stdio applies remote's number rule before a value becomes CLI text (BUG-4070) (#2313)
* fix(mcp): stdio applies remote's number rule before a value becomes CLI text (BUG-4070)
On stdio, a string for a "number" param reached the CLI verbatim. An int
flag parses it base 0 (pflag's ParseInt(s, 0, 64)), so "010" was 8, "0x10"
16, "1_000" 1000 and "+5" 5; pad_chat delete's positional seq parses base
10, so "010" was 10. Remote refuses all of them (decimalIntString,
BUG-4065). A JSON number was formatted and forwarded whatever its size,
where remote refuses one of magnitude 2^53 or more as possibly rounded
(wholeInt64, BUG-4061 /
|
||
|
|
ffecfc744b |
feat(chat): show chat delivered to an agent to the human at its terminal (TASK-4071) (#2312)
* feat(chat): tee monitor chat lines to a per-session chat-seen log (TASK-4071) pad watch --stream --for-session appends every chat line it prints to ~/.pad/chat-seen/<harness-pid>.log (0600 in a 0700 dir, O_NOFOLLOW), capped at 256 KiB with one .1 generation, so the human at the agent's terminal can see it. No log when the pid is pad watch's own; pruning a session record removes its log. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * test(chat): pin the chat-seen tee, perms, rotation, prune and C2 (TASK-4071) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * test(chat): refuse a FIFO that has a reader (TASK-4071 M13) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * feat(plugin): show chat delivered to the agent in a band and a toast (TASK-4071) The pad mod tails ~/.pad/chat-seen/<harness pid>.log from the session's first use of Pad, starting at the end of the file, and shows new lines in a band above the prompt (the last three, until the person's next prompt) and one toast per burst. Lines rotated away between polls are not shown. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * refactor(plugin): one rotation guard in the chat-seen reader (TASK-4071 P5) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * fix(chat): report a log that is off on its first line, and say what prune and the monitor do with it (TASK-4071) The monitor prints nothing before it has something to deliver (DOC-2479), so the no-harness-pid line waits for the first chat line. The pad watch --stream --for-session and pad session prune help now describe the log. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * fix(chat): r1 findings on the chat-seen log and reader (TASK-4071) - Refuse a symlinked chat-seen directory instead of chmodding its target. - Reopen a log that was removed under the writer. - Remove .log.1 before rotating: Rename does not replace on Windows. - Reader: drop control characters and direction overrides before they reach the terminal; clip by code points; one toast per burst on the relay's clock; a stop during start leaves nothing running; a failed first read no longer replays the log. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * fix(plugin): r2 findings on the chat-seen reader (TASK-4071) - The first look skips what the log held at its stat, so a line written between that stat and the read still shows. - A stop during a poll no longer leaves the reader stuck after the next wake. - An unreadable moment (failed stat, a symlink) changes nothing, so it cannot cause a replay. - U+061C joins the stripped bidi controls. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * fix(plugin): r3 findings on the chat-seen reader (TASK-4071) - Start a log at zero only when the listing shows it absent; a first look that merely failed leaves the start to the first good read (R3-1). - Restore the byte-size rotation reset beside the character one: a log of multi-byte text can shrink in one while it grows in the other (R3-2). Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg |
||
|
|
cba880ca17 |
fix(config): warn on a relative PAD_DATA_DIR / PAD_DB_PATH; anchor install-refresh's marker and config reads (BUG-4066 phase 1) (#2308)
* fix(config): warn once per process on a relative PAD_DATA_DIR / PAD_DB_PATH; install-refresh anchors its marker and config reads (BUG-4066 phase 1)
config.Load takes PAD_DATA_DIR verbatim, or PAD_DB_PATH's dirname, with no
filepath.Abs, so every process resolves a relative value against its own cwd:
a CLI and a server started in different directories silently use different
data (database, attachments, key). Phase 1 (lead ruling 4b4cb4ff) leaves the
resolution unchanged and makes it loud:
- One helper, envDataDir(), is the only reader of the two variables (Load and
userConfigPath both use it). A relative value warns ONCE per process,
naming the absolute path this process resolved, that a CLI and a server in
different directories will use different data, and that a future release
refuses it (TASK-4072). A literal '~' gets a hint. A CLI prints it to
stderr; `pad server start` logs it through slog; `pad watch --stream
--for-session` (the plugin monitor) suppresses it.
- install-refresh anchors the refresh marker and config_value the way 2a
anchors the log (${SERVER_CWD:-$PWD}), so the marker is written and
cleaned up in one place (it used to leak under the caller's cwd), and warns
before anything is read or stopped.
- docs/deployment.md: both variables must be absolute; relative is
deprecated and warns.
Tests: the warning rule (and Windows, GOOS-guarded), the marker and the
warning-before-stop end to end, config_value at function level, and
child-process tests for the monitor's silence and the server's slog line.
Harness guard (after this unit's draft test stopped the box's shared :7777
server twice): every install-refresh exec in internal/buildtools now passes
requireSafeScriptEnv first, which refuses an environment whose resolved port
is missing (the script would fall back to 7777) or is 7777, whose HOME or
data dir is the real user's or outside a test temp dir, or whose port answers
on a LAN address without this test's random stub token.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(config,mcp,scripts): the relative data-dir warning names the server's path and stays out of agent-facing errors; harden the test guard (BUG-4066 r1)
r1 rulings (c0ef54e8):
- install-refresh's warning now runs once SERVER_CWD is captured (still
before the marker and the stop) and names the anchored path
(${SERVER_CWD:-$PWD}/<dir>, a leading ./ stripped); when the caller's cwd
differs it also names the caller's reading. It used to name $PWD/<dir>,
which the server does not use.
- `pad mcp serve` checks the data dir once on its own stderr; the stdio
dispatcher's `pad` children get PAD_INTERNAL_NO_DATA_DIR_WARNING=1, so the
warning is not folded into a failed tool call's agent-facing error. The
variable is internal (code-documented only), so users cannot silence the
deprecation. Shell completion no longer prints the warning mid-Tab.
- Test guard: refuses a relative data dir with a config-sourced port (the
script reads that port from the server's cwd, which the guard cannot see);
compares symlink-resolved temp paths (macOS); asks for the stub token on
the LAN address that answered; compares 7777 numerically; and a source-scan
test fails if any install-refresh exec in the package is unguarded.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)
- PAD_DB_PATH warning names the resolved data dir (Dir of the value), in Go and in install-refresh.sh; the script normalises . and .. in pure shell.
- Shell completion (__complete, __completeNoDesc) sets the silent sink in main.
- MCP resource and bootstrap fetchers exec with dispatchChildEnv; a population scan pins every pad exec in internal/mcp.
- Harness guard: numeric 7777 refusal before any dial (asserted by message), PAD_HOST must be empty or loopback, temp-dir check scoped to this test, hardened and self-tested source scan, marker-presence probe in the restarted stub, child timeout, sink reset.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fixup! fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)
test(buildtools): the config-file 7777 row asserts the numeric refusal by message too (BUG-4066 r2 item 12, same class)
* test(buildtools,cmd): close the m15, m24 and m28 survivors (BUG-4066 r2)
m24: a scanner self-test case whose variable never runs in its function,
with a guard in a later function, so the function boundary is what flags it.
m28: insideTestTempDir refuses another test's directory, and admits a
subtest's, which t.TempDir names <Parent><sub...> with no separator.
m15: the --workspace completion func no longer sets its own sink. main()
silences every completion request, which made that line dead code.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(cli,buildtools): an auto-started server does not inherit the warning silencer; the guard reads the config port like the script (BUG-4066 r3)
Item 2: an MCP tool-call child carries PAD_INTERNAL_NO_DATA_DIR_WARNING=1
and is often the process that auto-starts the server, so that long-lived
server never logged the relative data-dir warning. serverStartCommand drops
that one variable from the spawn env, and keeps every other one. It is the
only `server start` spawn in internal/cli; a source test pins both facts.
Item 7: the harness guard took the leading digits of config.toml's port, so
"8080abc" read as 8080 where install-refresh rejects it and falls back to
7777. It now reads the first port line the way config_value does, and a
non-numeric value is refused as unreadable.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(scripts): install-refresh's relative-dir warning is one line; the completion tests run main() (BUG-4066 r4)
Item 1: when the server's cwd differs from the caller's, the warning printed
a second "Warning:" line naming the shell's-directory reading, and a third
for a leading ~. Phase 1's contract is one warning per process, as
config.Load prints it, so both now ride in the one line. The test asserts
both paths sit in that line and that it prints once.
Item 6: the completion child cases called silenceWarningsForCompletion
themselves, so deleting main()'s call passed every test. They now set
os.Args and run main().
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
|
||
|
|
bc2a32895e |
feat(chat): monitor reply quotes and a session-start DM ensure (TASK-4043) (#2309)
* feat(chat): render monitor reply references and excerpts (TASK-4043) * feat(chat): ensure owner-agent DM once per monitor process (TASK-4043) * docs(chat): describe monitor quotes and DM startup (TASK-4043) * test(chat): accept startup DM ensure in recovery fixture (TASK-4043) * fix(chat): wait a default when a 429 has no Retry-After (TASK-4043 r1) * fix(chat): bound Retry-After before it can overflow the wait (TASK-4043 r2) A Retry-After too large for a time.Duration (>= 9223372037s) wrapped negative and made the ensure retry hot. Seconds are now bounded before the multiply; zero, negative, absent and overflowing values all wait the 120s default, and the largest value that fits is honoured. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg * test(chat): keep the Retry-After table portable and hermetic (TASK-4043 r3) The overflow rows are int64 and skip where int cannot hold them; the rows run with a temp HOME. Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg |
||
|
|
82f81ee828 |
fix(mcp): refuse a non-number for a numeric parameter instead of reading it as absent (BUG-4065) (#2306)
* fix(mcp): refuse a non-number for a numeric parameter instead of reading it as absent (BUG-4065) numericInput read any value outside float64, int, int64 and json.Number as absent, so a string, a bool or an object for limit, offset or days was silently replaced by the default. itemExpectedSeqParam had its own switch with the same fall-through, so a string expected_seq sent the content write out UNGUARDED. Now only null is absent: - A string is admitted only as a plain decimal integer (an optional '-' and digits), parsed exactly and bounded like a JSON number (|x| < 2^53), so "10" is 10 and "123" is a guarded write. That matches what stdio's CLI flags and the buildQuery doors already accept (lead ruling af4bff40, Q1). Every other string, and every other type, is refused with validation_failed naming the parameter. - Every Go integer type is admitted exactly; an unsigned value above MaxInt64 is refused. - itemExpectedSeqParam, pr_number and chatSequence share that one rule (pr_number's bound becomes the exclusive 2^53; chatSequence's float64 gets the 2^53 bound and an exact json.Number). Their messages and hints are unchanged. - item patch validates expected_seq and edits[].line_hint locally, and the collection and role update doors validate sort_order, so each answers validation_failed naming the field rather than the server decode's 400. - buildQuery routes the native integers through the same check; strings are still forwarded verbatim. - The item-list resource's limit flag is refused when malformed, not defaulted. ToolSurfaceVersion 0.103 -> 0.104, with the changelog in version.go and docs/mcp.md; the README, instructions.md heading and wire goldens follow. Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV * fix(mcp): numeric query keys and leading-zero strings are refused, not defaulted or re-read (BUG-4065 r1) r1 found two Mediums (lead ruling 5a06782e): - The buildQuery doors forwarded a bool, an object or a non-integer string for a numeric key (item backlinks, item search, item context's budget, project activity, project standup and changelog). The server Atoi'd it and silently kept its default. Each route now names its numeric query keys (routeSpec.numericQuery, and the explicit lists at the direct buildQuery callers), and those keys go through numericInput. TestBUG4065_NoRouteForwards ANonNumberForANumberParam walks every remote route against every parameter its tool declares "number", so a new numeric key cannot be left off. - "010" was 10 here and 8 on stdio (pflag parses base 0). A plain decimal string is now ^-?(0|[1-9][0-9]*)$, so a leading zero is refused. The stdio side is BUG-4070. Also: an end-to-end row through the pad_item tool handler, value rows at the chat doors, an array value at every door, absent-means-default, the resource flag's negative row, stale expected_seq comments, and the v0.104 changelog corrected in version.go and docs/mcp.md. Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV * test(mcp): the numeric-param population walk cannot pass vacuously; uintptr admitted (BUG-4065 r2) r2 found the r1 population walk could pass on broken code (lead ruling b9025d7c): it looked for the raw bad value under the catalog name only, so a rename, a path segment or a value dropped as absent was invisible, and its "x"-filled inputs tripped unrelated validation, so most routes refused before reading the number. With a route's numericQuery dropped, the walk alone stayed green. The walk now uses a differential oracle from a curated valid base: each (route, number param) is dispatched with the param absent, as "7", and as a bad value. If "7" changes what is forwarded, the route reads the param and a bad value must be refused naming it with no write; otherwise a bad value must forward exactly what the absent case does. An inconclusive case fails. The derived read-set is pinned in bug4065ReadSet, so a route that stops reading a param altogether cannot be reclassified and go green. Also: uintptr is admitted like the other unsigned types; an empty string and false are named as refused in the v0.104 changelog; rows for a missing key, a null expected_seq (still unguarded), "" and a 20-digit string at every door. Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV * test(mcp): false, the catalog limit doors, and the chat null claim (BUG-4065 r3) r3 rulings (9bd7fa2d), all Low: - false is fed to the unit table, the door table and the population walk; the changelog says it is refused, and nothing pinned it. - pad_item.list and pad_item.history read limit through intParam before the dispatcher; TestBUG4065_CatalogLimitNonNumbers refuses "abc", true, false, an object, "010" and "" there naming limit, and uses "7". - The v0.104 changelog says pad_chat's numeric params still refuse a present null, as before, with the same message; "only null is absent" read as if they did not. - A misnamed row; the refused pr_number rows assert no request went out. Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV * docs(mcp): reflow a joined comment; state the population walk's limit (BUG-4065 r4) Comment-only (lead ruling 86de95f1): my r1 edit joined two comment lines in dispatch_http_advanced.go into one; and the walk's doc comment now says conditional reads are outside it, with edits[].line_hint covered by the door table. Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV |
||
|
|
8873978b1d |
test: close surviving-mutant gaps from the #2284-#2290 audits (TASK-4051) (#2307)
* test(push): verify configured gateway wiring (TASK-4051) * test(push): verify server shutdown stops dispatch (TASK-4051) * test(push): cover committed updates with lost row counts (TASK-4051) * test(notifications): cover owner and actor lookup errors (TASK-4051) * test(catch-up): cover caller scope and ceiling label (TASK-4051) * test(push): fail shutdown regression without blocking cleanup (TASK-4051 r1) * test(push): drive retry settlement across record chunks (TASK-4051 r1) * test(push): pin a gateway batch inside one record chunk (TASK-4051 r1 D2) Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg |
||
|
|
ba981cb4f2 |
fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061) (#2303)
* fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061)
numericInput converted a float64 with int64(x), so limit 1.5 read as 1, -2.7
as -2, and a value outside int64 as an implementation-defined number. A
json.Number that did not parse read as absent, so the parameter was dropped.
- numericInput returns an error for a fraction, a value outside int64 (2^63
is out), and an unparseable json.Number. intParam names the parameter.
- All 10 caller-input sites refuse with validation_failed: the route mappers,
item history, attachment list, and the pad_item.list and pad_item.history
catalog actions. pad_item.list's documented truncation of a fractional
limit to the default is reversed by ruling; zero still means the default.
- actionItemGet's read of the server's own seq stays lenient (pinned).
- itemExpectedSeqParam refuses a float64 outside int64 and gets the same
json.Number refusal, rather than dropping the token (an unguarded write).
- Comments note the unreachable float32 paths in formatNumber and
chatSequence.
- ToolSurfaceVersion 0.103: the parameters are declared "number", so the
refused inputs were valid under the advertised shape. Goldens move only by
the version string.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
* fix(mcp): buildQuery refuses a number that is not a whole int64 (BUG-4061 r1)
Round 1 (Codex Medium, and the hunter's standup/changelog question): the
query-string doors built by buildQuery (item backlinks, item context budget,
project activity, item search, project standup and changelog) forwarded
"1.5", and the server parsers read it as absent and used their default.
buildQuery now refuses with the key named, and its four callers return
validation_failed (lead ruling on Q2, option (a)).
- TestBUG4056BuildQueryNumbersUnchanged: the 2.5 and 1e19 rows are now
refusals, superseded by that ruling. The notation rows stand, and
json.Number "1e6" forwards as "1000000".
- jsonNumberInt64: a whole json.Number in exponent form ("1e6") is
accepted. Int64() alone refused it. Used by numericInput and
itemExpectedSeqParam.
- The v0.103 changelog door list is corrected. The instructions.md and
README version headings follow 0.103, which
|
||
|
|
d3f87d205a |
feat(chat): delivery, framing and the accept-instructions opt-in (TASK-4041) (#2305)
* WIP TASK-4041: add ref-only chat delivery and explicit acknowledgements * WIP TASK-4041: implement local consent and fetched monitor delivery * WIP TASK-4041: document chat delivery and session instruction consent * WIP TASK-4041: rename chat arm files (_arm.go is a GOARCH build constraint) * test(chat): fix delivery fixture and strengthen consent guards * test(chat): pin replay publication guard after mutation audit * test(chat): make review guards detect their named regressions * docs(chat): clarify bounded excerpts and defer unwired command guidance * test(chat): cover empty agent pairs and the Redis envelope * fix(chat): seed gap fills and acknowledge contiguous output * test(chat): restore gap regressions for amended ack rule * fix(chat): advance contiguous delivery across own rows and tombstones * test(chat): cover final tombstones, ack retries, and sync recovery * fix(chat): acknowledge final contiguous tombstone events * fix(chat): retry contiguous delivery ack on duplicate events * fix(chat): reseed delivery baselines on sync required * fix(chat): wire session consent commands and reuse chat client Pin arm/disarm execution through the real root tree and restore the deferred skill and monitor guidance. Wiring adds both local consent commands to the cmdhelp Document supplied to MCP. Preserve TestChatArmHasNoMCPPath's failure for the requested ruling; do not change MCP goldens or versions. * test(mcp): pin chat direction dispatch across transports * docs(cli): add chat direction command examples * test(chat): expose delivery memory loss and unsafe acknowledgements * fix(chat): retain process delivery memory independently of fill cursors * docs(chat): describe local delivery memory and failed-summary recovery * test(chat): pin both monitor adapter fetch limits * docs(api): state chat owner-message framing in the present tense (TASK-4041) The TASK-4039 provenance caveat described TASK-4041 behaviour as future work. It is implemented now: say what is true and point at the framing rules under GET /api/v1/events/stream. Claude-Session: https://claude.ai/code/session_01Cc68dt7DDR28fp7cxKYkbt * test(chat): expose failed resync, late retraction and identity collisions * test(chat): keep push delivery when monitor identity is unavailable * fix(chat): preserve closed recovery and retraction ownership per identity * docs(chat): clarify recovery, page cap and identity memory limits * test(chat): recover after a transient monitor identity lookup failure * fix(chat): retry transient identity failures on chat references * test(chat): require deferral until a safe server summary * fix(chat): defer events until a server delivery floor is available * test(chat): pin per-conversation server floors and caller boundaries * feat(chat): expose caller delivery floor on conversation GET * fix(chat): fetch per-conversation floors before monitor delivery * test(chat): require bounded synchronous first-contact delivery and backoff * fix(chat): fetch first-contact floors synchronously with bounded backoff * test(chat): remove redundant break from synchronous stream fixture * test(chat): cover stream deadlines, retry windows and cookie agent isolation * fix(chat): bound stream HTTP calls and back off failed conversations * docs(chat): clarify direction consent and stream delivery limits * test(chat): preserve independent dedupe eviction and retry clocks * fix(chat): require bearer authentication for stream agent addressing * test(chat): cover r10 tombstone, summary and arm-generation regressions * fix(chat): fill tombstone gaps and back off unread summaries * fix(chat): bind direction consent to a random arm generation * test: pin r11 retractions and session direction cleanup * fix: retract owed chat deletions before network backoff * fix: report session action when direction removal fails * docs: clarify chat direction rearming and connect state * test(chat): bound HTTP deadline failures and cover unresolved retractions * docs(chat): clarify immediate retractions and session re-arm direction loss |
||
|
|
0ed34ea9f7 |
fix(mcp): integral numbers keep decimal notation on both transports (BUG-4056) (#2300)
* fix(mcp): preserve decimal numeric arguments (BUG-4056) Share numeric formatting across CLI dispatch and HTTP queries, and validate remote chat sequences directly with int64 bounds. Preserve the existing tool surface and wire goldens. Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * fix(mcp): numeric array elements, playbook tokens and buildQuery's default share formatNumber (BUG-4056, r1) Lead ruling on r1 (tier A from here): toStringSlice spelled each JSON number element of an array with fmt.Sprint, so a repeatable flag or variadic positional given 1000000 got '1e+06'; playbook raw_args tokens used %v, so a string- or ref-typed argument would bind '1e+06'; buildQuery's default branch kept fmt.Sprint for float32. All three now use formatNumber. Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd |
||
|
|
51b0bb4de9 |
feat(cli,mcp): pad chat CLI and pad_chat MCP tool (TASK-4042) (#2295)
* feat(cli): add global pad chat commands (TASK-4042) Add conversation targeting, inbox and message commands with caller-owned idempotency and precise read markers. Preserve API responses for JSON output and use cwd workspace links for reference context. Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * fix(cli): add chat examples and isolate command tests (TASK-4042) Satisfy the CLI help-tree example contract and restore package-level flags after command regression tests. Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * feat(mcp): expose global pad_chat on both transports (TASK-4042) Add six chat actions with shared target parsing, uniform authenticated dispatch, precise read markers and structured unnamed-agent errors. Exclude agent chat from ChatGPT and pin the direction guard. Bump the surface to 0.102, update documentation and goldens, and shorten duplicated prompt prose within the fixed budgets. Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * test(mcp): label global chat scope judgments (TASK-4042) Mark the membership-based global-chat exemption reasons explicitly, as required by the allowlist coverage guard. Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * fix(chat): preserve unread gaps and successful outcomes (TASK-4042, seat r0) Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * fix(chat): restore agent rules within prompt budgets (TASK-4042, seat r0) Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * fix(chat): keep scope fixture contiguous with read marker (TASK-4042, seat r0) Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * fix(chat): guard read boundaries and direction inputs (TASK-4042, r1) Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd * fix(chat): validate MCP bodies and report mark outcomes (TASK-4042, r1) Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd |
||
|
|
f8756a0b5b |
feat(server,cli,plugin): agent presence — agent name, worker id and busy/idle activity (TASK-4040) (#2293)
* test: pin agent presence and worker-scoped session activity * feat: carry agent presence and track activity by user and worker * docs: describe worker presence and session activity states * fix: include hook activity examples in CLI help * fix(cli): keep session activity hooks silent on failures * fix(server): tolerate activity read failures in session lists * docs: place activity keys after presence registry details * fix(plugin): wire silent Claude Code activity hooks * docs: explain session busy-state hook limits * fix: bound the entire session activity command * fix: batch session activity reads across workers * docs: describe detached activity reports and timestamp ordering * fix: order worker activity atomically by client timestamp * fix: detach hook activity reports with captured timestamps * test: reproduce detached activity losing registered identity * fix: capture activity identity before detaching the reporter * test: isolate activity registry home on Windows * docs: clarify activity ordering and hook failure behavior * fix: bound activity reordering to ten seconds * fix: replace activity records with corrupt client timestamps * test: prove activity parent exits while child response is held * fix(session): derive activity reorder window from report bound * fix: replace non-string Redis session activity records * test: cover cleared and expired session activity ordering * test: cover older timestamp after legacy activity * test: cover corrupt activity nanos and malformed JSON * test: verify activity child deadline and process isolation * docs: clarify read-only tokens cannot report hook activity * test: strengthen session activity liveness and timing guarantees * test: pin malformed null session activity timestamps * test: harden session activity detach regression checks * test: skip activity process-group check when ps is unavailable * test: tie activity parent-exit bounds to the HTTP timeout * test: bound activity liveness checks by the child HTTP deadline * test: require ps for process-group checks in Go CI |
||
|
|
fdbba1a31e |
feat(cli,mcp): pad project catch-up and pad_project action=catch-up, read-only (TASK-4035) (#2290)
* feat(cli,mcp): pad project catch-up and pad_project action=catch-up, read-only (TASK-4035)
Read-only wrappers over GET /workspaces/{ws}/catch-up on the CLI, local and
remote MCP, and the ChatGPT surface (catch_up). Mark caught up (PUT /me/seen)
stays web-only (ruling 380aa793): no verb, flag or client method moves the
cursor. ToolSurfaceVersion 0.100 -> 0.101, additive. Existing tool
descriptions were reworded to fewer bytes (no rule, caveat, refusal or ref
dropped) so tools/list keeps 901 B of headroom under an unchanged ceiling.
Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5
* test(cli,mcp): ChatGPT catch_up in the wire golden and the leak sweep (TASK-4035)
Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5
* fix(cli,mcp): catch-up json skips the typed decode; the re-indent claim is corrected (TASK-4035)
--format json prints the server's JSON re-indented without the typed
decode; table and markdown keep it. The MCP description names reopened.
Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5
|
||
|
|
8204f03d25 |
feat(server): push dispatch timings are configurable, and a bad value refuses startup (TASK-3939) (#2288)
* feat(server): push dispatch timings are configurable, and a bad value refuses startup (TASK-3939) Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5 * docs(deployment): push gateway env vars, dispatch timings and the cleartext warning (TASK-3939) Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5 * fix(server): the lease refusal names the cause, and an overflowing margin is refused (TASK-3939) SetPushDispatchConfig wraps errPushLeaseTooShort with the resolved lease, send timeout and margin; configurePush names both knobs and appends it. A margin within sendTimeout of MaxInt64 is refused before the sum can wrap. Whitespace-only values stay malformed and are now documented and tested; values are otherwise uncapped. Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5 |
||
|
|
eaa1b0da16 |
feat(push): start the push dispatcher at server startup when a gateway is set (TASK-3939 B2c) (#2286)
* feat(push): start the push dispatcher at server startup when a gateway is set (TASK-3939 B2c) Server startup now calls configurePush, which sets the gateway URL and, when one is configured, refuses a missing secret, sets the gateway client and starts the dispatcher. With no URL nothing starts: no device can register, so nothing is queued. Shutdown already stops the loop with the server's other background workers. The comments that said nothing starts the dispatcher are corrected. Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5 * docs(push): configurePush's comment says what happens to queued rows with no gateway (TASK-3939 B2c r1 L1) Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5 |
||
|
|
aad3c35bdb |
fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024) (#2279)
* fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024) `pad item list --format json` stopped at the CLI's own 200 default and said so on stderr only, so a consumer parsing the array saw 200 of 204 rows with no signal in the payload. Ruling: with --format json and no --limit (cobra Changed, not a value compare) the CLI follows X-Pad-Next-Offset to the end, sends no limit and prints no notice. The payload stays a plain array; no API, schema or MCP change. An explicit --limit, including --limit 0, keeps today's behaviour and its stderr notice. --limit 0 has always meant "the default" (clamped to 200), not "no limit"; a test pins that. Table and markdown are untouched. --offset with no --limit pages from that offset to the end. One private pager (listAllPages) now backs ListAllItems and ListAllCollectionItems (the old names stay as wrappers) and new *Report variants taking a start offset and returning the first page's undeclared filter keys, so the BUG-3480 warning still fires. A non-advancing next offset, or more than 1000 pages, is an error and a non-zero exit with a message naming the cap, never a partial array. This also applies to the existing callers (reconcile, github status, project standup), which used to return a short list for the non-advancing case. Claude-Session: https://claude.ai/code/session_013aLLQx2EARjEzmWHbExjtm * fix(cli): the list pager requires has-more pages to be full and contiguous (BUG-4024 R2-F1) listAllPages accepted any next offset greater than the current one, so an empty has-more page, or a next offset that skipped rows never returned, followed by a has-more=false page, ended the loop on a partial list with no error. The server sets has-more only when it trimmed the probe row (handlers_items.go listItemsPage), so such a page holds exactly `limit` rows and next-offset == offset+len(items) with len(items) > 0. The pager now requires exactly that and otherwise returns an error with nil items. The check subsumes the old `NextOffset <= offset` test. Tests: empty page (advancing and non-advancing next offset), a jump, and one short, at the client level; the jump at the `pad item list --format json` level (non-zero exit, empty stdout). Claude-Session: https://claude.ai/code/session_013aLLQx2EARjEzmWHbExjtm * fix(cli): the list pager reads a negative start offset as 0, as the server does (BUG-4024 R3-F1) Claude-Session: https://claude.ai/code/session_01UiMM6MkrYr1nkuaNkSmkeD |
||
|
|
20a150064f |
feat(push): gateway client and notification claim lifecycle (TASK-3939, part A of 3) (#2278)
* feat(push): gateway client and notification claim lifecycle (TASK-3939, part A) internal/notify: the push gateway client (X-Cloud-Secret, no redirects, capped response, 400 -> ErrBadRequest, everything else -> ErrUnavailable, results pinned to the request) and the fixed content-free bodies. internal/store: claim/settle/expire/release/prune for user_notifications, the live-device query (session joined on id and user_id and unexpired; the newest live registrant of a shared token wins), and the item liveness read. PAD_PUSH_GATEWAY_SECRET with the cloud outbound secret as fallback; startup fails when a gateway URL is set and no secret resolves. No dispatcher yet. Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * test(push): shared-token tie in both directions and no-leak log capture (TASK-3939, part A) Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * fix(push): claim read-back is scoped to the call; drain gateway bodies; config no longer imports billing (TASK-3939, part A r1) Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * fix(push): each claim gets its own token; the drain test proves connection reuse (TASK-3939, part A r2) ClaimPendingNotifications stamps caller token + per-call random nonce and returns it as Claim.Token; mark, skip and release take that token, so reusing a caller token in the same second cannot read back another call's rows. The gateway drain test now asserts connection reuse via httptrace. Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * test(notify): derive the drained body size from maxResponseBytes (TASK-3939 r3-L1) Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm * fix(store): clamp a notification claim to 1..notify.MaxBatch (TASK-3939 r4-L3) SQLite reads a negative LIMIT as unbounded and the read-back IN list is not chunked. Also labels the no-secret-in-errors gateway test as a pin (r4-L5). Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm * test(store): a zero claim limit clamps to 1 (TASK-3939 r4-L3) Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm * fix(store): a claim limit <= 0 claims nothing and writes nothing (TASK-3939 r5-M1) Replaces the 0 -> 1 promotion: a caller asking for no rows must not be handed a lease nobody will send. A limit above notify.MaxBatch still clamps to it, so "never exceeds limit" holds as written. Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm |
||
|
|
527fa47e0f |
fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) (#2275)
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) models.User no longer carries the secret. GetUserQ and its siblings, the member and backlink reads and the mention-recipient check therefore cannot fail on a secret that will not decrypt. checkSecondFactor loads it through the new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP branch (an empty one never verifies), a decrypt failure logs once without secret material and fails closed, and the recovery-code branch is untouched. Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1) - checkSecondFactor writes its 500 directly: writeInternalError logged the same error a second time. T2 counts every ERROR record except the request logger's per-request line. - T1's Stripe lookup now matches the undecryptable user (it looked up an unknown customer and could not go red), and ListUsers and SearchUsers are pinned to return the row with its 2FA flag. - The appstore boundary allowlist no longer says GetUserQ decrypts, and drops the deleted decryptUserTOTP. - Two comments no longer claim a single reader of the column. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2) An enabled row whose secret is empty cannot verify a code, so the user is held to recovery codes; checkSecondFactor now logs one warning naming the user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and the test placeholder helper drops its unused argument. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3) user.TOTPEnabled is the request's snapshot, and a disable landing after it also reads as no secret, so the warning no longer says 2FA is enabled; it says there was no secret to verify a code against. T5 asserts the new text. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019) The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty case wrote '' and returned at stored=="" first, and encrypt("") returns "" rather than sealing it, so dropping the guard left every test green. The store test now writes a hand-sealed empty plaintext to an enabled row and expects ErrNoTOTPSecret; a server test refuses a code computed from "" on that row. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5) EnableTOTP also decrypts the stored secret (to compare) and fails on one that will not decrypt, so the header no longer says the second-factor check is the only thing that fails. TOTPSecretForVerify remains the only reader that returns the plaintext. Comment only. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 |
||
|
|
d76f06d199 |
feat(server): push device registration and notification settings API (TASK-3938) (#2269)
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938) RegisterPushDevice is one transaction: delete rows on either unique key, evict the oldest at the 20-device cap, insert. RotateUserSessions mints the replacement, rebinds the caller's own devices, deletes the other sessions, and fails closed to revoke-all. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * feat(server): push device and notification-settings routes, push_available (TASK-3938) Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT /me/notification-settings, push_available on /auth/session, PAD_PUSH_GATEWAY_URL, and all credential-change rotations through RotateUserSessions. docs/api.md updated. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(server): classify GET /me/notification-settings in the compress census (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1) * fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1) * test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938) Reuse sessions across rounds and widen the count-to-insert window with a test-owned slow insert trigger, so removing the user-row lock overshoots the cap on every run. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1) * fix(server): push_available goes to a signed-in caller only (TASK-3938 r2) |
||
|
|
5035168e5c |
fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1) r1 rulings (lead, on BUG-3982): - F1: docs/api.md says details.token_workspace is absent for a deleted pin. - F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one statement); any other id is the missing-id 404. - Q1: an MCP tool mapped to a global route (pad_workspace create) is refused for a pinned PAT, tested through wireMCP. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test(e2e): the suite token is unpinned (BUG-3982 CI) global-setup minted the browser suite's token with a workspace_id on a stale premise (api_tokens once required one). Under BUG-3982 a pinned token is refused the global routes the UI calls on every page, so the E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and /auth/export. task-3415's deliberate pin is kept. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3) GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter, but the token route gate read "import-status" as a slug and admitted a pinned token, which could then read the user's import outcome for any workspace by key. It joins reorder, import and deleted as a refused segment. The route walk skipped every route the gate admits, so it could not catch a route admitted wrongly. Each admitted route must now sit inside the workspace subrouter or be in a hand-written expected list, and the list must match what the gate admits. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4) r4 rulings (lead, on BUG-3982): - On every door that takes a workspace by name (RequireWorkspaceAccess, SSE /events?workspace=, search ?workspace=), a pinned token naming any other workspace is refused before the name is resolved (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug get byte-identical refusals. The resolved-ID compares stay behind it. - The email-restricted share link and the invitation preview read the caller through signedInFor: a token pinned to another workspace counts as signed out there. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4) The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant rerun showed that mutant surviving. This direct test, shaped like TestBUG3982_WorkspaceAccessComparesThePinByID, holds it. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5) r5 rulings (lead, on BUG-3982): - authorizeCrossWorkspace refuses a pinned token naming any other target_workspace before resolving it, so copy and preflight give a missing, non-member and member target the same refusal. The OAuth allow-list check, reported after the role check, is unchanged. - The /-/r/ resolver checks the pin by name before resolving; its answer was already the same 404. - Both join the identical-bytes test; the resolver also gets a by-ID test, since its resolved-ID compare is now behind the by-name one. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6) r6 rulings (lead, on BUG-3982): - authorizeCrossWorkspace compares the pin by resolved ID behind its by-name check, like RequireWorkspaceAccess, SSE, the resolver and collab, with a by-ID test. - Collab's resolved-ID compare gets the by-ID test the others have. - docs/api.md: five BUG-3982 sentences appended without a separator now end the sentence before them. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 |
||
|
|
0e051112e5 |
fix(cli): 2fa disable exits non-zero when the rotated session cannot be saved (BUG-3882) (#2264)
* fix(cli): 2fa disable exits non-zero when the rotated session cannot be saved (BUG-3882) The disable revokes this CLI's session and hands back a replacement. When saving it failed, the command warned and exited 0, so a script carried on with a dead token. It now keeps the warning and returns an error that points at 'pad auth login'. No other CLI path saves a rotated token: every other credentials write already returns its error. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(cli): 2fa disable also fails when the server sends no new session (BUG-3882) The disable has revoked this CLI's session either way, so a 200 without a replacement token leaves it signed out just as a failed save does. Exit non-zero there too, pointing at 'pad auth login'. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd |
||
|
|
07215d4953 |
fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970) (#2249)
* fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970)
Dave: "the plugin should not be doing anything when a session is started."
session.start now only registers /pad-pane and restores the pane. It no
longer runs `pad session register` / `should-arm`, no longer spawns
pad-monitor.sh, and no longer starts the 5s refresh timer, which now runs
only while the pane is open.
wake() runs once per session, on the first pad Bash command, a Pad MCP tool
call, or opening the pane. The first pad command waits for registration
(never for should-arm), because register mints the worker id that leases key
on. A .pad.toml push.auto_arm opt-in is therefore read at first use.
Tests: session start runs no process and asks for no timer; non-pad Bash
wakes nothing; wake runs once; an MCP tool wakes it; registration happens
before the first pad command runs. Prose swept (skill, monitor script,
CLAUDE.md, registry comments); docs/plugin-releases.md gets the stuck-0.3.x
remedy.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): a pad command inside a shell string or after an assignment wakes the session (TASK-3970)
Hunter r1 F1: `bash -c "pad ..."`, `sh -c 'pad ...'` and `X=... pad` missed
the first-use match, so that call ran before presence was recorded. The
class now takes quotes and `=`; a false positive only wakes early.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): pad as a whole word, checked against a table of shapes (TASK-3970)
Round r2 found two more missed shapes (Codex: `\pad ...`; hunter: a bare
`pad` before a quote, `)` or `;`). Rather than add a third case, the match
is now `pad` as a whole word, not inside a longer name. watch.test.ts
tables 25 shapes that run pad and 8 that do not, and asserts that
registration ran before each matching command reached the tool
(Codex r2 note).
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): a Pad MCP tool wakes the session whatever the server is called (TASK-3970)
Codex r3: the matcher required "pad" in the server part of the tool name,
so a Pad server registered under another name (work-tracker) was missed.
It now matches any mcp__<server>__pad_* tool. The tests table Pad tools
under four server names and four tools that are not Pad's, and add ./pad
and ../bin/pad to the Bash shapes.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(plugin): the pane refreshes after a Pad MCP edit under any server name (TASK-3970)
Hunter r4: the pane-refresh matcher still required "pad" in the server
name, so it disagreed with the wake matcher fixed in
|
||
|
|
1e9b87759e |
feat(store): @mention index on comments (TASK-3917) (#2243)
* feat(store): comment_mentions index, parser, backfill (TASK-3917) Index of which humans a live comment @-mentions, written in the comment's transaction at every writer (create, edit, tombstone, fenced app doors, import), with a startup backfill. Migrations 154 (sqlite) and 123 (pg). Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(appstore): allow-list reindexCommentMentionsTx in the fence boundary (TASK-3917) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(appstore): the comment write census permits comment_mentions (TASK-3917) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * fix(store): comment mention index r1 fixes: skip imported, cap, parser rules, created_at, guard (TASK-3917 r1) F1 imported comments are never indexed (import, edit, backfill); F2 cap of 50 names; F3 parser "/" "\" "_" "." non-ASCII rules; F4 guard rewritten with per-decl statement counts over internal/ and cmd/; F5 created_at follows the comment (edit time for a newly added mention); F6 FOR KEY SHARE on PG; F8 backfill logs per-comment failures. Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(store): pin the backfill created_at against an aged comment (TASK-3917 r1) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * fix(links,store): follower and prefix rules, cap after resolution, guard ignores comment lines (TASK-3917 r2) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(links),docs(store): mid-word "_" prefix row; F6 skip comment names what reopens it (TASK-3917 r2) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * fix(links): linear URL check for "/" before "@"; store docs and guard test (TASK-3917 r3) r3 F1: the "/" prefix rule walked back to the previous whitespace for every match, so "/@a" repeated was quadratic (240 KB took ~10 s, in the comment write transaction). A forward scan now tracks the run start and the last "://" once per call. F2: the reindex doc no longer says ONE query. F3: the guard test legs are isolated. F4: callsReindex documents its fail-open cases. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * fix(links): mention code-span check walks the spans once per call (TASK-3917 r4) isInRanges scanned every code range for every match, so many inline code spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor over the sorted, disjoint ranges advances with the increasing match position. ExtractWikiLinks has the same shape and is left for its own item. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * test(links): a row where the code-span cursor skips several spans at once (TASK-3917 r5) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * docs(links): CJK letter after a mention is a documented limit (TASK-3917 r3 F5, ruling 6250d6ec) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 |
||
|
|
16aaaf5450 |
feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919) A collection whose items are asks of a person (IDEA-3906): the needs-you router will treat an open, assigned item in it as "asked". Stored as settings.awaits_human like tracks_work: absent means false, a settings write that omits it keeps the stored value, a typed member on create and update sets it alone, and a non-boolean is refused 400. No migration (A3 default). - models: CollectionAwaitsHuman[JSON], Carry/Validate/Set, on shared bool setting helpers that the tracks_work functions now use too - store: create and update apply it; update carries it under the row lock - CLI: pad collection update --awaits-human[=false], checked on the response - web: "Items are asks of a person" toggle on the editor's Display tab - docs/api.md Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX * fix(collections): read awaits_human per key, not through CollectionSettings (TASK-3919) A plain bool field made a stored non-boolean awaits_human (an import is not validated) fail the whole CollectionSettings unmarshal, so a reference collection read as work. CollectionAwaitsHumanJSON now reads the one key. Also corrects the --awaits-human comment: the flag shape is not MCP-safe. Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX * test(store): assert the returned settings after turning awaits_human off (TASK-3919) staticcheck SA4006: the UpdateCollection result was assigned and never read. Claude-Session: https://claude.ai/code/session_01Fbkk7f7M5JqZE1Gc4ZSSYj * fix(cli): pad collection create takes --awaits-human (TASK-3919) Codex r2 P2: only `collection update` had the flag, so marking a human-tasks collection took a second call. create now sends the typed member and, like update, refuses to report success when an older server ignored it. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd |
||
|
|
ffc11387e1 |
feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)
Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
removed / changed / superseded / current state, and the advisory
possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
criterion hash is stored against the body it came from, via resolved
in-workspace and visibility-checked, activity "verified", item_updated
SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
and reconcile.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* docs(api): the verifications endpoint (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)
- F1: when the item moves between the criteria read and the write, the
retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
item that keeps moving (409), and a grant revoked mid-retry (404).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)
- The retry re-runs the via resolution and its visibility check, the same
re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)
- F1: VerificationExport, exported for the bundle's live items and
imported with the item and via ids remapped, every other value
verbatim. Each bundle row is checked before its insert (a refused
statement on Postgres loses the whole import); a row that fails, or
whose item did not import, is dropped and counted. Tests: a SQLite round
trip, the migrate-to-pg path (SQLite export into a Postgres import with
the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)
The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)
#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
|
||
|
|
b13e842740 |
fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879) (#2222)
* fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879) A failed tool call answers the /mcp POST with 200 and an error result, so the audit middleware, which classified only the transport status, wrote result_status=ok for every refused write. MCPAuditLog now installs an outcome sink on the request context; a tool-handler middleware on every MCP server pad builds records a failed result's error code (and details.reason) into it. permission_denied, auth_required and rate_limited are denied; any other failure is an error; the code is the error_kind. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * test(mcp): unit rows for the tool-outcome sink, codes and middleware order (BUG-3879) Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * docs: the MCP audit status is the call's outcome, not the transport's (BUG-3879) Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * fix(mcp): a JSON-RPC error inside a 200 is audited too, via an OnError hook (BUG-3879 hunter r1 M1) mcp-go refuses an unknown tool, and answers a missing resource or prompt, before the tool-handler middleware runs, so those calls were still audited as ok. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * test(mcp): a failed tools/list audits as error; the prose names what OnError cannot see (BUG-3879 r2) Lead ruling R2 pins the unscoped hook; hunter r2 L1 narrows the claim: a message rejected before method dispatch never reaches OnError. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * test(mcp): a refused write's audit kind carries the reason BUG-3864 added (BUG-3879) After rebasing onto #2218 (BUG-3864), the 403s for a read-only token's write and a viewer's write carry details.reason, token_scope and role. recordToolOutcome writes error_kind as <code>:<reason> whenever a reason is present, as designed, so the two transport rows now expect permission_denied:token_scope and permission_denied:role. On the old base those 403s carried no reason, which is what the rows had pinned. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * fix(mcp): an unverified email's refused call is audited as denied (BUG-3879 r4) Since BUG-3864, a write refused because the account's email is unverified answers with its own MCP code, email_not_verified, rather than permission_denied. classifyMCPToolOutcome only knew the older three refusal codes, so after the rebase onto #2218 that refusal was audited as error. It is denied now, as the classifier's own comment and this bug's contract already said. The plan and member limits stay errors (BUG-3894 owns that policy). The unit rows that paired permission_denied with an email_not_verified reason described a payload the server never sends; they now use token_scope, a reason it does send, plus a row for the real code. A new transport row drives an unverified owner's write on the Cloud fixture and requires a denied row. docs/api.md and the metric's Help text name the code. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR |
||
|
|
ed89604ad8 |
fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* fix(mcp): a refused call names its cause on both transports (BUG-3864) A 403 from one of the shared gates now carries details.reason (role, token_scope, no_access, workspace_not_authorized), with code and message unchanged. MCP builds one permission_denied envelope from it on both transports, with a hint that says what to do next. It used to lead with "Insufficient role" for every 403, so a bot with a read-only API token was told to ask for a role. An unverified Cloud email is reported as the server's own email_not_verified code. Stdio gets the same envelope from a structured marker the CLI root writes for a 403 coded forbidden, permission_denied or email_not_verified. Before it, the token-scope and unverified-email refusals said server_error there. ToolSurfaceVersion 0.99 -> 0.100 (behavior). Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): the inline workspace-role gates name their reason too (BUG-3864) The bulk endpoint, the owner-only member doors and the owner-only app install and upgrade doors say role, or no_access for a guest. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(mcp): the permission hint is the same on both transports (BUG-3864) The remote hint appended the route, which stdio has no way to know, so one refusal reached the two transports with different hints. The tests compared prefixes only and missed it; both now assert the transports agree exactly. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): no role hint for a caller with no role to change (BUG-3864) requireMinRole and the grants list said role for a guest, who holds grants rather than a role, and for a caller with no workspace role at all. Both now go through roleGateReason, which answers no_access for either. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi |
||
|
|
b901ec629c |
feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)
Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.
Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)
Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)
Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.
describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)
Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.
The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
|
||
|
|
eac92797f4 |
fix(store): a workspace slug is slugified whichever door supplies it; migrate-to-pg keeps existing slugs (BUG-3769) (#2205)
* fix(store): a workspace slug is slugified whichever door supplies it (BUG-3769)
An import with a name override used the raw name as the slug ("Interchange
Golden", answered 201), and the workspace's own export URL could not be
built. The same defect had three more doors: an import bundle's own slug
and a create body's slug landed verbatim, and a name slugify keeps nothing
of (all punctuation or all non-ASCII) minted an empty slug.
createWorkspaceQ, which every mint goes through, now slugifies the supplied
slug, falls back to the name's slug, then to "workspace", as roles, items
and collections fall back to theirs. The import override sets the name and
leaves the slug to be derived from it.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* test(server): import tests name their imports in slug form (BUG-3769)
They looked the workspace up by the raw import name, which was the slug
only because of the bug. Four were absence checks (Gone, Dup, Husk, the
interrupted ones) that the fix alone would have made vacuous, so the names
are lowercased rather than the lookups slugified.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* test(server): the import tests' URLs and quoted names use the slug form; the clean-import GET must be a 200 (BUG-3769)
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* docs(api): a workspace slug is canonical and the response's slug is the one to use; the fallback test is order-independent (BUG-3769)
Codex r1 Medium: a client that builds URLs from the slug it sent can miss
now that every door slugifies it, so docs/api.md states the rule and that
the 201's slug is authoritative, including the bundle re-canonicalization
the hunter's r1 L1 describes. Hunter r1 L2: the punctuation-only fallback
case no longer depends on the table's order.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* fix(store): pad db migrate-to-pg keeps each workspace's slug verbatim (BUG-3769)
Hunter r2 Medium: the migration copies existing workspaces through
ImportWorkspace with no name override, so the new canonicalization renamed a
legacy slug (my_ws, Foo) on the way to Postgres, breaking URLs, bookmarks and
.pad.toml links, and two slugs that canonicalize alike collided into -2.
A MintOption, WithPreservedSlug, keeps a supplied slug verbatim (still made
unique) in createWorkspaceQ. migrateImportWorkspace, which the command now
calls, passes it; every door that mints a new workspace leaves it off. The
docs bullet names the exception.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
|
||
|
|
7825f3afc5 |
fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) (#2197)
* fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) An idle MCP event stream made no write, so #2182's write deadline never fired on it: a client that stopped reading but held TCP open pinned a handler goroutine and its session until a notification happened to be written, and a signed-in client could open any number of them. - A ': keepalive' comment at the /events cadence on any response the transport commits as 200 text/event-stream, written through the same bounded writer. mcp-go writes each SSE event with one Write, so a mutex at Write boundaries keeps keepalives between events. No keepalive after a JSON-RPC response (the final event of a POST stream) or after the handler returns. mcp-go's own heartbeat is not used: it sends ping requests every client must answer. - The shared streamAdmission gate, keyed by streamPrincipal, taken at request entry for the two long-lived doors: GET /mcp and a subscriptions/listen POST. A refusal is the same plain 429 sse_limit_exceeded with Retry-After 5, sent before the transport runs. A tool call upgraded to SSE mid-call is not counted: a refusal there would land after its side effects. Listen is detected by decoding the whole body exactly as mcp-go does (encoding/json into a "method" field), not by a fixed-size peek that key order could evade. - Docs, config, the startup log and the metric help say the budget now covers MCP's long-lived streams. Credential expiry for /mcp streams is BUG-3837. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * docs(mcp): the keepalive's shutdown window, the event-store premise and the chatgpt mount, stated precisely (BUG-3835) Codex r1 (Medium, proposed declined): a keepalive tick between the transport's return and end() can add one comment frame; it is still inside mcpBoundWrites, which joins the keepalive goroutine before returning, so net/http never sees a write after the handler. The comments now say so. Codex r1 (Low): GET /mcp/chatgpt is admitted too; the docs named only /mcp. Hunter r1: the one-Write premise holds only without an event store (writeSSEEventRaw writes twice); stated at the premise. Detection of a stalled idle client takes up to about three keepalive intervals; stated in docs/api.md. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): a panicking transport stops the keepalive; MCP streams are stated long-lived without a credential re-check (BUG-3835) Hunter r2 (Low-Medium): the keepalive shutdown ran after next.ServeHTTP without defer, so a panicking transport left it running past the handler, with chi's Recoverer swallowing the panic. Now deferred; pinned by TestMCP_BUG3835_PanickingTransportStopsTheKeepalive (red when not deferred). Hunter r2 (Medium): docs/api.md and two code comments said no MCP route is long-lived. The MCP transport's GET and subscriptions/listen streams are, and do not yet re-check their credential; that is BUG-3837. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * docs(mcp): the keepalive catches dead peers; a live non-reader is bounded by the budget, not detected (BUG-3835) r3 hunter Medium: with default socket buffers a comment every 30s takes days to fill them, so the "caught within about three keepalive intervals" claim held only in the shrunk-buffer tests. api.md, the file header comment and the stalled-reader test comment now say what the keepalive catches and what the budget bounds. Codex r3 Low: deployment.md's stream gauge and checklist now include MCP's long-lived streams. The end()/ended comments state the one-frame window between the transport's return and end(). Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): the admission body read is in the body census; dead-peer detection is stated as TCP gives it (BUG-3835) r4 blind hunter: - M1: mcpLongLivedRequest reads the request body and was not in the body reader census, so TestEveryRequestBodyReaderIsAccountedFor failed. It is now listed, with the reason and the second-buffer cost the lead accepted. - M3: a half-open peer accepts keepalive writes into the socket buffer until TCP gives up on it; "within one keepalive plus the write deadline" held only in the shrunk-buffer tests. api.md and the header comment now say so. - L3: the panicking-transport test waits for its first keepalive, read under the writer's mutex, instead of sleeping a fixed 10ms (M7 still red). Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): admission reads a POST body only when its protocol header is modern; r6 fixes (BUG-3835) Pad serves the legacy MCP protocol versions only, and mcp-go serves a subscriptions/listen only on a modern-era POST whose Mcp-Protocol-Version header is modern and matches _meta. So a POST without a modern header is never a listen, and admission no longer reads its whole body a second time (r6 hunter M1, gate approved by the lead). The docs, the metrics help, the startup log and the census reason say listen is counted once the modern era is served. api.md states the keepalive outlives a proxy idle timeout until BUG-3837, the deployment note says the per-user bucket is shared with the browser's /events, and a test pins isFinalSSEEvent against the real transport's response frame. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): admission looks for a listen only on a protocol version the transport serves; r7 fixes (BUG-3835) The listen gate now also requires the header's version to be one the mount's transport serves, read through mcp-go's SupportsProtocolVersion (forwarded through the ChatGPT wrapper). A client probing an unserved modern version at a full budget reaches mcp-go and gets the 400 it falls back on, not a 429 (r7 ruling). Pad serves the legacy versions only, so no POST body is read for admission today, which the docs now say. New tests pin that mcp-go never serves a listen without a modern header (with a control that it serves a well-formed one), that an unserved modern version is not read or counted, and that both production mounts report their served versions. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): the served-version gate is checked against the transports pad server mounts (BUG-3835) r8 L2 (lead ruling): internal/mcp's wiring test builds its own transports, so a shim around either production binding would silently send admission back to counting every modern version. Server.MCPMountServesVersion exposes the gate's read of the transport the server holds, and a cmd/pad test drives wireMCP and asserts both /mcp and /mcp/chatgpt report their versions. Shim mutants on each binding turn it red. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * docs(mcp): the bus and mcpBoundWrites comments name the MCP stream slot and keepalive (BUG-3835) r5 hunter 3 and hunter 6's doc half, accepted by the lead (16:04Z): internal/events/bus.go said streamAdmission is acquired by "both endpoints", falsified once MCP's long-lived streams acquire it too, and mcpBoundWrites' doc comment did not mention the admission slot or the keepalive. Comment-only. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * docs(api): the stream budget's 429 is stated once, and the MCP keepalive has its own paragraph (BUG-3835) r5 hunter 2, accepted by the lead (16:04Z): the admission paragraph stated the 429 refusal twice, the second time after the keepalive text it no longer fitted. Docs-only. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR |
||
|
|
02806238d6 |
fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) (#2194)
* fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) Workspace import stored each archive item's fields as carried, and nothing checked unique_scope: workspace_collection. An import creates a new workspace, so a duplicate can only come from inside the archive (an export taken after the BUG-3812 race stored one, or a hand-edited bundle). The new workspace then held two live items with one unique value, and every later write to either that carried it was refused 409. A duplicate invocation_slug failed the whole import instead, on idx_items_invocation_slug_per_collection, as a 500 on the JSON door and a 400 carrying the raw constraint on the bundle door. As ruled: drop and report, the BUG-2367 rule for carried values. The first item in archive order (export order: created_at, id) keeps the value; each later item loses only that key. The unique keys are every unique_scope: workspace_collection field plus invocation_slug, whose index covers every collection whatever its schema declares. invocation_slug is compared as both dialects' indexes compare it: Postgres by its ->> text, SQLite by json_extract's typed value. A value collides if either would, so an archive that imports on one dialect imports on the other. A blob with nothing dropped is stored byte for byte. Each drop is reported as (item, field, value, holder), refs in the new workspace, in a new additive dropped_unique_values member of both import doors' 201. pad workspace import prints each one in move's "dropped:" shape. Import checks no required fields, before or after this change, so a dropped value on a required field leaves the item without it, reported like any other drop. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import compares invocation_slug numbers exactly, as each index reads them (BUG-3825 r2) Codex r2 M: float64 rounding made 2^53 and 2^53+1 one value, so a value neither index equates was dropped. Hunter r2: Postgres's ->> prints a jsonb number with the scale written, so 1.0 equals the string "1.0" there, and the normalised slot missed it. Numbers now claim their exact value (SQLite) and their numeric output text (Postgres), with no float. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import compares invocation_slug numbers as SQLite's REAL does, and bounds the exponent (BUG-3825 r3) Codex r3 H / hunter r3 M1: SQLite's json_extract returns an int64 literal as INTEGER and anything else as a binary64 REAL, so 0.1 and 0.10000000000000000001 are one value there; the exact big.Rat slot kept both and the index failed the import. The SQLite slot is now the int64, or the float64 (as an integer when it is exactly one). Codex r3 M / hunter r3 M2: big.Rat expanded 1e999999 into a million digits per item. Postgres's numeric text is now built from the literal's digits, and an exponent beyond +-1000 stands for itself. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import keys a Postgres number by value and scale, not expanded text, and checks the slot model against both databases (BUG-3825 r4) Codex r4 M: the +-1000 expansion cap missed 1e1001 against its expanded digits on Postgres. Two jsonb numbers print the same ->> text exactly when value and scale match, so the Postgres slot is that pair, which does not grow with the exponent; a string in numeric's output form shares it. Hunter r4 asked whether SQLite's own decimal parser and Postgres's numeric limits match the model. TestBUG3825_SlotsMatchTheDatabase asks the database under test, for every pair of 58 values, whether its index would see them equal, and requires that dialect's slots to collide exactly then. It caught one mismatch on Postgres: jsonb renders an object as {"a": 1}, so objects and arrays are compared on Postgres only with each other. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): bound the numeric key's exponent; the slot oracle skips only Postgres's out-of-range refusal (BUG-3825 r5) Codex r5: an exponent near MaxInt overflowed len(intPart)+exp; beyond 2^24 the literal now stands for itself (Postgres refuses such a number). Hunter r5: the oracle skipped every Postgres error; it now skips only SQLSTATE 22003, counts them, and covers negative decimals and zero with an exponent. The composite residual is documented for both dialects. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
fdcf0867cd |
fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808) (#2182)
* fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808) The /mcp sibling of BUG-3637. mcp-go's GET stream, and a POST response it upgrades to SSE, write and flush with no deadline; the server sets no WriteTimeout and no timeout ends a tool call. A client that stopped reading pinned the handler, its session registration and the connection. mcpBoundWrites serves both MCP mounts (/mcp and /mcp/chatgpt) through #2172's deadline-arming stream writer, on every method, and cancels the request context when the stream goes dead: the library leaves its loop on a Write error, but a failed Flush returns none, so ctx.Done() is its way out. It fails closed like openSSEStream. Tests that drove /mcp through httptest.ResponseRecorder now use the deadline recorder. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): the dead-stream context cancellation is tested directly; the flush case is named for what it shows (BUG-3808) M2 (Dead not wired to the context) survived the integration test: frames still queued in mcp-go's channels after a failed Flush make the next Write fail on the sticky error, so the loop leaves anyway. The context is its only way out when nothing is queued, so that binding gets its own test. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): both MCP mounts refuse an unbounded writer (BUG-3808) Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): cmd/pad's /mcp tests serve through a writer that takes a deadline (BUG-3808) Five cmd/pad tests drove /mcp through httptest.ResponseRecorder, which mcpBoundWrites now refuses. mcpRecorder is the cmd/pad twin of the server package's deadline recorder. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): a header-only /mcp response is committed under a write deadline (BUG-3808) mcp-go's DELETE calls WriteHeader and returns; net/http commits that response in finishRequest, after mcpBoundWrites has returned and with the deadline openSSEStream cleared. mcpBoundWrites now arms one deadline for that final write; net/http clears it once the response is finished. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): the /mcp stall tests wait for the failed write, then bound only the release (BUG-3808) BUG-3824 changed stalledStreamServer to report the first failed write on the watched connection and split the stalled-reader timing in two: the buffer fill is the harness, the release after a deadline overrun is the code under test. The GET and DELETE stall pins had the same single 5s end-to-end budget that made BUG-3637's flaky under load; they now use the same two phases and require the failure to be a deadline overrun. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
a99a040cf3 |
fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) (#2170)
* fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) [WIP: no Codex round yet]
Save used os.WriteFile, which applies 0600 only when it creates the file
(BUG-3648) and truncates the live file first, so a concurrent reader could
see an empty store or half a document (BUG-3649). Save now writes a 0600
temp file in the same directory, syncs it and renames it over
credentials.json, and creates ~/.pad 0700.
The four load-modify-save callers (device login, saveCredentials, logout,
2FA session rotation) go through the new cli.UpdateStore, which holds a
cross-process lock on credentials.json.lock (flock on unix, LockFileEx on
windows, none elsewhere) across load, mutate and save. Logout makes its
server call outside the lock and re-reads the file under it to delete.
Tests: credentials_bug3648_test.go, plus a guard that fails on any
LoadStore-then-Save outside UpdateStore. Mutants C1-C4 and the guard's
counterfactuals are recorded on BUG-3649.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the credentials lock file is tightened to 0600, and a wipe takes the lock (BUG-3648, BUG-3649) [WIP]
Codex round 1 Lows: OpenFile's mode applies only on creation, so an existing
lock file kept a loose mode; WipeCredentialsFile removed the file outside
the lock, so a concurrent UpdateStore could rename it back.
Test: an existing 0666 lock file reads 0600 after UpdateStore.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* test(cli): a credentials wipe waits for the credentials lock (BUG-3649)
Lead ruling on Codex round 1 L1: hold the lock, start the wipe, assert it
has not returned and the file is still there, release, assert it finishes.
Dropping the wipe's lock reddens it 3/3.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): UpdateStore callbacks must not call WipeCredentialsFile (BUG-3649)
Codex round 2 (Medium): a wipe inside an UpdateStore callback would wait on
the lock that UpdateStore holds. No caller does this (the wipe has no
production caller); both doc comments now say so.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): tightening the credentials lock file is best effort (BUG-3649)
Both pilot bug-hunters (Haiku and Sonnet, blind): fchmod on a lock file
another uid owns returns EPERM, which failed every UpdateStore and so
every login. The lock file holds no secret, and the token's protection
is credentials.json's own 0600 temp-plus-rename, so the chmod error is
ignored and the lock is still taken. The owner-case tightening test
stays. (Lead ruling: accepted.)
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): the best-effort chmod covers a writable foreign-owned lock file, not a sudo-created one (BUG-3649)
Both pilot hunters on
|
||
|
|
47fe81ab99 |
fix(cli,mcp): explicit-limit callers read every page or say they are truncated (BUG-3793) (#2154)
* fix(cli,mcp): pad reconcile and pad github status read every page, and the MCP items resource says when it is truncated (BUG-3793)
Three callers set an explicit list limit and treated the capped answer
as complete: pad reconcile read one 500-row page of the workspace, pad
github status read 100 rows of each collection, and the MCP items
resource (pad://workspace/{ws}/items, 200 rows) read as the whole
workspace with no sign it was cut.
reconcile now uses ListAllItems, and github status a new
ListAllCollectionItems (its collection scan moved into
githubStatusScanItems so it is testable); both follow the server's
next-offset to the end. The resource asks for one row past its cap on
both transports (stdio --limit 201; remote fetchItemList honors the
flag) and, when that row comes back, serves 200 rows plus a second
text/plain block saying more exist. A list that fits is unchanged.
Resources are outside the tool catalog: no ToolSurfaceVersion move.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(cli): the completed-work report pages until the cutoff (BUG-3793, Codex r1 Medium)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
fc9ceae92f |
fix(api): children, starred and shared-collection lists are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) (#2153)
* fix(api): the children list, the starred list and a shared collection are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) Each returned every row with its body: the children of an item, the caller's starred items, and every item of a shared collection to an anonymous reader. They now take the BUG-3704 contract: limit defaults to and is capped at 1000, offset skips rows, and X-Pad-Has-More / X-Pad-Next-Offset carry the paging state (the share payload, a JSON object, also says has_more and next_offset in the body). Each order ends on the id, so offset pages neither repeat nor skip rows that tie. Children: store.ListChildItems applies the caller's visibility in the query, so the window and has-more count only visible children, and the has_children probe reads one visible grandchild instead of every grandchild with its body. Starred: read without bodies, filtered for visibility and terminal status (which stays in Go), windowed, and bodies are loaded only for the page (store.ItemBodies). Share link: ListItems takes the window. Callers walk every page: the web children and starred lists by offset until a short page (request() exposes no headers; rows de-duplicated by id), the web share page by next_offset (merging items and attachment refs), including the password retry, and pad item starred by the server's next-offset. Each walk fails past 50 pages rather than return a partial list. MCP pad_item action=starred never pages (lead ruling, as BUG-3704 for list): both transports ask for one row past the 300 cap (remote ?limit=301, stdio the new `pad item starred --limit`) and say has_more with the truncation note. ToolSurfaceVersion 0.99. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(api): an offset past the end of the starred and children lists is [] (BUG-3789, BUG-3790; lead check) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
858bd8514e |
fix(api): item lists are bounded at 1000 with has-more paging, and filter-key probing is bounded (BUG-3704, BUG-3703) (#2143)
* fix(api): a list request's field filters are bounded, and the stored-key probe stops at the first absent key (BUG-3703) checkListFilterKeys asked StoredFieldKeys about every undeclared filter key, each a scan of the list's scope, and refused only afterwards: N absent keys cost N scans. The first absent key already decides the 400, so the probe stops there (one scan, however many keys), and a request carrying more than 50 field filters is refused before any store work. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(api): item lists are bounded at 1000 with has-more/next-offset headers, page stably, and page on ?search= too (BUG-3704) A missing, zero, negative or invalid limit on GET /items and the collection item list returned every matching item with its body. It now takes the maximum (1000, lead ruling: an old client loses rows only on a list past it), an explicit limit is clamped to it, and a limit+1 probe sets X-Pad-Has-More (always) and X-Pad-Next-Offset (when more). The list sort always ends on i.id, so an offset page cannot repeat or skip rows that tie, and the ?search= path applies the offset it used to drop. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(cli,mcp): item lists report the server's exact has-more; pad item list gains --offset; pad_item list says when it is truncated (BUG-3704) pad item list keeps its 200 default (TASK-2000) and replaces the len==limit guess with the server's X-Pad-Has-More, naming the next step (--offset N) on stderr in every format; --offset is new, matching item search and backlinks. pad project standup's in-progress section pages through next-offset (ListAllItems), since a report needs every row. pad_item list asks for one row past its limit and, when it comes back, trims it and sets has_more with a note; it never pages (lead ruling). An untruncated result is unchanged. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(mcp): ToolSurfaceVersion 0.98, pad_item list says has_more when truncated (BUG-3704) Additive on the v0.53 grounds; golden initialize bytes regenerated (the version and the instructions heading are the only moves). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): pages that need a whole collection page through it; WebMCP pad_item list is bounded and says has_more (BUG-3704) listAllByCollection walks a collection in pages of the server's 1000 cap. The library page's active-convention/playbook maps, both playbook pages' invocation_slug uniqueness lists and the conventions page use it, since each silently misbehaves on a short list. WebMCP pad_item list had no default at all; it now mirrors the Go catalog (50, max 300, one probe row, has_more + note when cut short). docs/api.md documents the bounded, paged list and the 50-filter cap. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(web): page mocks and identity-gate rows follow listAllByCollection (BUG-3704) The library, playbooks and conventions page tests mocked listByCollection, which those pages no longer call. The TASK-3097 identity gate pins library loadData (and its three fetch continuations, whose hash takes the 120 characters before the call) and the playbook editor's loadPlaybooks: re-reviewed, the checks still run after the one awaited call, so a result whose pages straddled an identity change is discarded. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(mcp,web): a fractional pad_item list limit takes whole rows (BUG-3704, Codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
223c9734d8 |
fix(cli): a workspace or ref string cannot re-route a request, and PAD_TOKEN is not sent to a server only .pad.toml named (BUG-3645, BUG-3646) (#2127)
BUG-3645: workspace slugs and refs were concatenated raw into request paths, so a .pad.toml workspace of "victim/#" turned `pad item delete TASK-1` into DELETE /workspaces/victim/. Every dynamic segment in the client builders now goes through url.PathEscape. Client.newRequest refuses a path with a raw '#', an empty, "." or ".." segment (PathEscape leaves dots alone), or a '/' after its '?', which catches any builder that misses escaping, plus the raw-path methods' callers. A reflection census calls every *Client method with a hostile string in each string argument and asserts that the string stays escaped inside one segment. BUG-3646: PAD_TOKEN was attached to whatever URL the client was given, including a committed .pad.toml's url. It is now bound to the server chosen before that override (--url, PAD_URL, the config or the default). Requests to a server that only .pad.toml names use the credential saved for it, and the CLI warns once. Setting PAD_URL to that server or passing --url opts in. Every check of whether the env token is in play (whoami, server info, init, login/logout, 2fa) goes through the same accessor. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
aa49db396e |
feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619) (#2110)
* feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619) Until now, no plan's storage limit was enforced: the only check was an async log line after the insert. Pro was unlimited by code. - CreateAttachmentForLiveItem enforces the owner's storage_bytes limit inside its own transaction when passed WithPlanLimit (cloud mode only), for user-visible originals only; variants never refuse. The upload and image transform doors pass it and answer 403 plan_limit_exceeded (feature=storage_bytes, limit/current/requested in bytes). The message names the plan and says existing files stay available. - Pro: DefaultProLimits.StorageBytes = 1 GB, pooled per ACCOUNT across every workspace the owner owns, soft-deleted ones included until purged. Free: DefaultFreeLimits.StorageBytes = 100 MB per workspace (Dave, PLAN-3605). Self-hosted stays unlimited. - The lock is an owner-keyed advisory lock taken first, so concurrent uploads into one account's workspaces serialise (proved on Postgres). - SeedPlanLimits rewrites a stored storage value only where it is still the old seed (Pro 10737418240, Free 524288000), and logs once. - Web: Pro payers get "Need more? Contact us" (support@getpad.dev). Editor, comment and transform refusals raise the plan-limit prompt, fenced on identity. Settings → Storage and pad workspace storage say when usage is account-wide. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): comment upload refusals raise the storage prompt only for the identity that started them (TASK-3619, codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(storage): conditional seed rewrite; stale-identity storage refusals are silent (TASK-3619, codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the storage check verifies the owner it locked, retrying on a mid-check ownership change (TASK-3619, codex r3) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
559d9d1422 |
feat(server): the item brief carries only the decisions Pad would surface, and counts the rest (TASK-3618) (#2107)
The brief listed every typed-decision row for an item with no threshold filter. On 40 recent docapp items that came to 2210 rows, 2202 of them below the threshold at which any surface shows them, and all of them were pushed into `omitted` by the conventions that fill the budget first. - decision.surfacing is the one row test: the question is current and the Noul reaches the threshold. WorkspaceFlags (the dashboard) and the new Runner.SurfacedDecisions (the brief) both call it. - QuestionSet.Threshold carries each set's surfacing threshold from the existing named constants (AttentionThreshold, ConventionThreshold). - The brief adds decisions_below_threshold and decisions_stale_question counts. It raises stale_decision only for a surfaced answer, and it keeps a surfaced answer whose item has moved on (current=false), which WorkspaceFlags drops. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
0ecec82357 |
fix(cli,server): a rate-limited health probe no longer starts a second server, and a start that loses the port touches nothing (TASK-3620 PR A) (#2106)
* fix(cli): a server that answers at all holds the port, so a 429 on the health probe no longer spawns a second server (TASK-3620) * fix(server): pad server start binds before it opens the database, so a start that loses the port touches nothing (TASK-3620) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): the liveness probe asks whether the port is held (a TCP connect), so a server still starting is not spawned over (TASK-3620, Codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): separate "is the port held" from "is it serving"; a server still starting is waited for, never spawned over (TASK-3620, Codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): the auto-start wait waits for a child still migrating, as EnsureServer does (TASK-3620, Codex r3) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
ba35908f2b |
fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) (#2103)
* fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) Verifying the guide's step 5 found it wrong for the people it is written for. A project linked to a self-hosted server pins that server's url in .pad.toml, and the pin wins over the global cloud mode, so `pad workspace link <slug>` talked to the OLD server, found the slug and printed "Already linked". With --url it still did, whenever Cloud had the same slug, which is the usual case after an import. - link is "already linked" only when the existing link's url equals the target. An explicit --url naming another server re-points it, and the output names the old and the new server. - WriteWorkspaceLink UPDATES .pad.toml instead of rewriting it: only workspace and url change, so agent_name, the [push] consent table and unknown keys survive every relink (link, switch and the three init paths). A file it cannot parse is refused and left as it was. - Step 5 now reads `pad workspace link <slug> --url https://app.getpad.dev`. - Lead ruling: the guide as a whole steers to Pad Cloud, so inside a mobile app's web view the route shows one line ("Open Pad in a browser to move a workspace") and both entry points are hidden. The entry points are one component carrying that gate, with a wiring test for each mount. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): link compares server URLs normalised, so case, a default port or a trailing slash is not a different server (TASK-3612, codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): server URL comparison keeps escaped path distinctions and strips one trailing slash only (TASK-3612, codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
a6f96107f4 |
feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
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
|
||
|
|
d94a38978f |
feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612) (#2100)
* feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612)
The pieces of a move to Cloud existed (export a bundle, import it through
New Workspace) but nothing connected them, and nothing said whether the move
was complete: the import answered with the bare workspace and the CLI printed
fixed text. An attachment whose rehydrate failed was a logged skip under a 201.
- Server: the bundle import's 201 carries an additive import_summary
{in_bundle, imported}, counted for collections, items, comments and
attachments. in_bundle comes from the bundle's arrays and manifest;
imported from the new workspace's rows (store.CountWorkspaceContents),
with the export's own filters, never from the import loop's bookkeeping.
- CLI: pad workspace import prints both columns and marks any difference.
- Web: the import toast states the counts and turns into an error when they
differ. A guide at /console/move-to-cloud (export, sign in, import, check
the counts, reconnect agents, invite the team; it is a copy, and members
do not carry). Linked from settings General on self-host and from the
Import tab on Cloud.
- Docs: api.md, backup.md (its export section still described JSON), README.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(import): a count mismatch is named as a difference, not a shortfall; the guide's Cloud sign-up link is gated off in the mobile apps (TASK-3612)
Codex r1 (two P2s): an imported count can also EXCEED the bundle's, so
the CLI and the guide now say the counts differ rather than that
something fell short.
Lead ruling (day 92) on the TASK-3299 classifier: mcp.getpad.dev is
KEPT (a connection address), app.getpad.dev is GATED. The guide links to
the Cloud sign-up only off Cloud and outside a mobile app's web view; on
Cloud it names the session's own MCP address. A page test asserts the
link is absent in the native shell, against a browser control.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
fbba819eb8 |
feat(cli,mcp): pad item context and pad_item action=context (PLAN-3598 slice (c)) (#2099)
The CLI and MCP doors onto GET /items/{ref}/context (slice (b), #2094):
one deterministic brief for working an item.
- `pad item context <ref> [--intent] [--role] [--budget]`: --format json
prints the server's body verbatim; the default markdown render lists the
item, parents with parsed acceptance criteria, conventions, playbooks,
dependencies, decisions, links, conflicts and what was omitted for size.
- client.GetItemContext.
- MCP `pad_item.action=context`, read-only: passThrough to the CLI on
stdio, and the HTTP route with intent / role / budget on the query.
New `intent` and `budget` params; `role` is now also read by it.
- Every registry guard: field-conflict exclusions, read-only cmdPath and
cmdhelp, ChatGPT exclusion, compact description header, instructions.md
(cut to stay under the initialize budget), the README table.
- ToolSurfaceVersion 0.96 (additive), after TASK-3611's 0.93-0.95; docs/mcp.md entry; golden wire
files regenerated.
Tests: the markdown render (every section, nearest-parent body only, an
empty brief renders no sections) and the MCP route mapping (goes red when
budget is dropped from the route's queryParams).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
1204a31a73 |
feat(mcp): pad_item link-pr links a PR from supplied metadata, and unlink-pr removes it (TASK-3611) (#2098)
MCP had no PR-link action: the `field` description sent agents to a human, because `pad github link` reads the local branch and asks `gh`, and a field write of github_pr is refused (BUG-2696). An agent that opened a PR could not record it on the item. - `pad github link <ref> --pr-number N --pr-url U [--pr-title --pr-state --pr-branch --pr-repo]` links a PR the caller names. Nothing is fetched from GitHub, and it needs neither a checkout nor `gh`. Without the flags the command behaves as before. Under --format json, link and unlink answer with the item. - pad_item.action=link-pr / unlink-pr, on both transports. Remote PATCHes the typed github_pr / clear_github_pr members, and unlink-pr refuses an item with no link, as the CLI does. The catalog's `ref` is passed as the commands' item-ref positional. - github link / unlink leave the remote CLI-only table; github status stays. The BUG-2696 guard is re-aimed: these two must be routed, and no remaining hint may advertise the github_pr field write. ToolSurfaceVersion 0.95, additive (0.93 is #2096, 0.94 is #2097). Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
bcb8d93516 |
feat(mcp): pad_item reminders lists an item's reminders and unremind disarms one (TASK-3611) (#2097)
An agent could arm a reminder and acknowledge a fired one over MCP, but
listing an item's reminders and disarming one were CLI-only, so an agent
that deferred work could not see or cancel what it armed without a
terminal. Two pad_item actions close that:
- reminders (ref): `pad item reminders`, GET /items/{ref}/reminders,
read-only;
- unremind (reminder_id): `pad item unremind`, DELETE /reminders/{id},
which needs edit permission on the item, as on the CLI.
Re-arm stays CLI-only.
ToolSurfaceVersion 0.94, additive (0.93 is #2096).
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|