Commit graph inspector/clients
Author SHA1 Message Date
cliffhall
a1c00e51a8 test(web): cover the App-level listed-resource metadata wiring
The bridge-factory tests inject `getListedResourceMeta` directly, so
nothing caught App.tsx ceasing to supply it — the fallback would go
unused with every test still green. Add a pass-through spy over the
factory in App.test.tsx and assert both factories receive a lookup that
resolves a listed resource's `_meta` and misses on an unknown URI.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 20:55:35 -04:00
Cliff Hall
8b9839cc93
Merge pull request #2087 from modelcontextprotocol/v2/fix/1914-surface-post-add-refresh-failure
fix(web): surface a failed post-write server-list reload
2026-08-23 20:48:01 -04:00
cliffhall
0c989a0c34 fix(web): use the resources/list entry as the UI metadata fallback
The read RESULT's own `_meta` is not a documented carrier for the sandbox
hints. ext-apps documents the `resources/list` entry's `_meta.ui` as the
static default a host reviews at connection time, with a read content
item's own `_meta.ui` taking precedence (McpUiAppResourceConfig). Swap
the fallback accordingly and thread the listing through a new optional
`getListedResourceMeta` dep, wired in App.tsx from the resources list.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 20:42:06 -04:00
cliffhall
06c7985d5c fix(web): read MCP App UI resource metadata from _meta.ui
McpUiResourceMeta describes the value of a `_meta` bag's `ui` key, not
`_meta` itself, so casting `content._meta` to it left `csp` and
`permissions` undefined for every spec-conforming app. The generated
sandbox CSP then fell back to `connect-src 'none'` and the app's declared
connectDomains never took effect.

Unwrap `_meta.ui` (with a result-level fallback, which the spec allows)
through a small type-guarded helper, mirroring the tool side of this file
where `getToolUiResourceUri` already reads `_meta.ui.resourceUri`. The
test fixture now builds the real wire shape, and cases cover the
result-level fallback plus the unnested bag being ignored.

Closes #2055

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 20:27:58 -04:00
cliffhall
bc433bbf72 fix: keep the object branch's __proto__ handling when filtering per key
A regression from the previous commit. The object branch was a spread, which
*defines* properties and is therefore safe; rewriting it into a per-key loop to
filter unserializable values turned it into `out[key] = value`, which *sets* —
so a persisted `"__proto__"` key vanished on reload and, with an object value,
gave the returned metadata a caller-controlled prototype. The legacy branch had
already been fixed for exactly this.

Fixed at the root rather than the site: both branches now go through one
`defineMetadataKey`, so a change to how one stores a key cannot leave the other
behind. That split is what let the earlier fix fail to protect this path.

The test uses `JSON.parse('{"__proto__":…}')`, not an object literal — a
literal `__proto__:` key sets the prototype at construction and creates no own
property, so a test written that way passes against the broken code. Confirmed
by reverting the line: only the parsed case fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 20:18:38 -04:00
cliffhall
56d8644032 fix: stop leaking metadata values into diagnostics, and salvage per key
Two credential leaks, the same class already fixed for the client logger:
`serverList` printed the malformed entry verbatim — a legacy row missing `key`
still holds whatever the user configured — and the CLI overflow error echoed
the whole `key=value` pair into stderr and CI logs. Both now name the key and
the offending *type*; a test plants a `sk-live-…` and asserts it appears
nowhere while the key still does.

`normalizeStoredMetadata` now also drops a value that parses but cannot be
sent. A hand-edited catalog is the reachable source: it is parsed server-side,
so `1e400` really does become `Infinity` there, and the next write would store
`null`. Filtered per key, matching the per-item salvage this file already does
elsewhere, so one bad value does not cost the user the rest.

Deliberately *not* added to the `/api/servers` guard, which review also
suggested. Writing that test is what showed why: a client's own
`JSON.stringify` turns `Infinity` into `null` before the request is sent, so
the wire cannot carry the bad case at all — and the check made things worse,
rejecting the whole metadata field where the normalizer drops one key. The
guard stays a container check, with a comment recording both points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 20:03:37 -04:00
cliffhall
7f5992081e fix: reject non-finite numbers before they reach _meta
`StrictJsonValue` removed `undefined`, but the type cannot express the rest of
the gap: TypeScript has one `number`, and JSON has no encoding for `NaN` or
`±Infinity`, so `JSON.stringify` rewrites them to `null`.

This is reachable from ordinary input, not a careless caller.
`JSON.parse('{"a":1e400}')` returns `Infinity` on overflow — so text a user
types parses cleanly, satisfies the type, and reaches the server as `null`
while the editor still shows `1e400`.

`isSerializableJson` in `core/json` is the runtime half of `StrictJsonValue`,
applied at both boundaries that turn text into metadata:

- the editor's draft parser, which now reports "Numbers must be finite" inline
  and withholds the value rather than emitting one it cannot send;
- the CLI's `--metadata` / `--tool-metadata` parser, which throws. Silently
  sending `null` would be wrong, and so would falling back to the literal
  string — neither is what was asked for.

Tests cover the validator directly (including that `JSON.stringify` really does
write `null`, which is the reason it matters), the editor parser, and the CLI
flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 19:43:39 -04:00
cliffhall
b1996d1093 Merge branch 'v2/main' into v2/fix/1914-surface-post-add-refresh-failure
Both lines changed the same two settings-write paths in App.tsx:

- #2089 (v2/main) rewrote the pagination toggle and the settings-draft
  flush around `useLastPersistedSettings`, so each write announces itself
  with `begin` and confirms with `landed`/`failed`.
- #1914 (this branch) made a failed post-write list reload reject with
  `ServerListReloadError` — the PUT landed, only reading the list back
  failed.

Resolved by treating a `ServerListReloadError` as a *landed* write in the
new model rather than a failed one: both paths now settle it exactly as
the success path does (so it becomes the rollback baseline and re-applies
itself when settled) and only the toast says the reload failed. Rolling
it back would put the UI, the live client and the modal's seed on a value
disk no longer holds — the very contradiction #2089 exists to prevent.

The two suites' new tests were interleaved by the conflict and are both
kept, plus one covering the combination: a modal save whose reload failed
is recorded as the landed write instead of rolled back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 19:37:27 -04:00
cliffhall
129a435fd4 docs: say "safe integer" for progressToken, matching what the code enforces
The copy said `progressToken` may be "an integer" while the implementation
validates with `ProgressTokenSchema` — zod's `.int()`, which rejects anything
past `Number.MAX_SAFE_INTEGER`. This repo's own tests assert that
`MAX_SAFE_INTEGER + 1` is dropped, so the documentation was promising a value
the client discards.

Corrected in all three places, so the code comment, the docs callout and the UI
hint agree: `dropInvalidReservedMeta`'s doc block (which review did not flag),
the `metadata` reserved-keys callout, and the Request Metadata hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 19:31:50 -04:00
Cliff Hall
06c7334b24
Merge branch 'v2/main' into v2/feat/1910-complex-request-metadata 2026-08-23 18:55:24 -04:00
cliffhall
616dd9c196 fix: exclude undefined from _meta values, and cover the JSON worker
- `JsonValue` admits `undefined`, so `RequestMetadata` accepted values that do
  not survive serialization: `JSON.stringify` drops an `undefined` object
  member and rewrites an `undefined` array element to `null`. The type
  contradicted the one promise this payload makes. Added `StrictJsonValue` /
  `StrictJsonObject` and used them for the metadata path.

  Deliberately not a change to the repo-wide `JsonValue`: for tool *arguments*
  `undefined` legitimately means "not supplied". The narrowing is sound
  wherever it lands here, since every one of these values comes from
  `JSON.parse`, which cannot produce `undefined`. The CLI's `parseKeyValuePair`
  needed the same tightening — caught by `clients/cli`'s typecheck, which the
  web `tsc -b` does not cover.

- Nothing asserted the JSON worker's annotations, and the wrapper's own "Not
  valid JSON" message comes from our parser rather than from Ace — so a broken
  worker URL would have left every test passing while the gutter diagnostics
  silently disappeared. Added a Storybook play function that waits for a real
  `session.getAnnotations()` error entry, and verified it fails when the worker
  module URL is pointed at a bad path.

- `aria-describedby` referenced only the error, so a supplied `description` was
  never announced and would have been dropped entirely once the draft went
  invalid. Both ids are referenced now, with the description surviving the
  error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 18:27:28 -04:00
Cliff Hall
89b01d01ff
Merge branch 'v2/main' into v2/feat/2082-file-secret-store-lock 2026-08-23 18:09:04 -04:00
cliffhall
1eaebdaf63 test: assert the dropped-member warning by its binding, not its message
Generalizing the sanitizer changed the warning to one message shared by every
reserved member, moving the offending key into the log's bindings — but this
test still matched on the old message text, so it stopped finding the call at
all. Caught by `npm run ci`.

Asserting `{ key, received }` is the more durable check anyway: the message is
now common to all reserved members, while the binding is what identifies which
one was dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 18:06:37 -04:00
cliffhall
167b9b4dab fix: validate every reserved _meta member against the SDK's own schema
`progressToken` was not the only reserved key. The pinned SDK's
`RequestMetaSchema` also constrains `io.modelcontextprotocol/related-task` to
`{ taskId: string }`, and a conforming server rejects the *whole request* over
one bad reserved member rather than ignoring that key. Before #1910 every value
was a string, so only `progressToken` could be wrong; the widening made all of
them reachable.

Replace both hand-rolled checks with the SDK's schemas — `RequestMetaSchema`
for the payload, `ProgressTokenSchema` for the single value. The schema is
loose, so unknown keys pass through untouched, and zod reports the offending
top-level key as `issue.path[0]`, so only that member is dropped and its
siblings survive. A member the SDK constrains in a later release is then
covered without anyone remembering to add it — which is the drift that produced
the `isInteger`/`isSafeInteger` bug one round ago.

`name` was hard-coded, and react-ace uses it as the editor container's DOM id,
so two instances emitted duplicate ids. Derived from the per-instance id, and
deliberately distinct from the id given to the textarea — that one is what the
wrapper's `<label for>` targets, so reusing it would just move the collision.

The docs table, the docs prose and the form hint all claimed "any JSON" without
qualification, presenting a configuration as valid that the client then drops.
All three now name the reserved keys and say what happens to a bad one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 17:54:39 -04:00
Cliff Hall
2aeec1728e
Merge branch 'v2/main' into v2/feat/1910-complex-request-metadata 2026-08-23 17:53:25 -04:00
cliffhall
7f10fa971c fix: address the Copilot review of the Ace editor
- `isProgressToken` used `Number.isInteger`, but zod 4's `.int()` rejects
  anything past `MAX_SAFE_INTEGER` — verified against the pinned zod — so an
  unsafe integer passed the guard and was left for the server to reject. Now
  `isSafeInteger`, with the boundary covered in both directions.

- `echoed` held only what this component last emitted, so it went stale the
  moment an external value was applied: A → B → A stranded the editor on B,
  because the second A matched the entry left from before B arrived and was
  dismissed as our own echo. It now advances to whatever is on screen.

- `Input.Wrapper` associates a *Mantine* input through context, and Ace is not
  one — it renders its own DOM. The wrapper's label and error node therefore
  pointed at nothing: the editor was never marked `aria-invalid`, never
  described by the error text, and its visible label did not focus it. Wired
  explicitly through the wrapper's id in an effect.

- Removed two leftover `[dbg]` logs an earlier regex cleanup missed.

- Documented the editor integration and the shared `cssVariablesResolver` in
  the web README, per the rule about new dependencies and patterns. Both
  footguns are recorded: the `?url` worker import, and the gutter override
  keying off Ace's cssClass rather than the theme module name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 17:36:43 -04:00
Cliff Hall
9bc6ce1a68
Merge branch 'v2/main' into v2/feat/2082-file-secret-store-lock 2026-08-23 17:22:17 -04:00
Cliff Hall
d186f09e6c
Merge branch 'v2/main' into v2/chore/2000-three-small-cleanups 2026-08-23 17:21:23 -04:00
cliffhall
75f9c51146 fix(web): update a fixture the merge brought in to the object metadata shape
`useLastPersistedSettings.test.tsx` arrived on `v2/main` with
`metadata: []` — correct when it was written, but this branch changes
`InspectorServerSettings.metadata` to a JSON object (#1910). Caught by
`tsc -b`, not by the tests, which pass either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 16:56:53 -04:00
cliffhall
63f81151f5 test(web): stop the AppRenderer theme-flip test racing its MutationObserver
Unrelated to this PR's subject, and pre-existing — it arrived with #1854. The
test set `data-mantine-color-scheme` and asserted the bridge push after a
single `await Promise.resolve()`, but how many ticks happy-dom takes to deliver
a MutationObserver callback is load-dependent. Observed failing once in a full
`npm run ci`, with 0 calls; passes standalone and on re-runs.

Adding a dependency the size of `ace-builds` shifts worker timing enough to
expose it, so fixing it here rather than leaving a known-intermittent failure
in the gate for whoever hits it next. `waitFor` retries the same assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 16:55:23 -04:00
cliffhall
ed799a0c0b feat(web): use the Ace editor for the request-metadata JSON
A textarea is a poor place to hand-write a nested `_meta` payload. Ace closes a
brace when you open one, folds nested objects, and — through its JSON worker —
marks the offending line in the gutter instead of reporting one message for the
whole document.

Three things the swap turned up:

- Ace fires *two* change events for a replace (a remove, then an insert), so
  select-all-and-retype briefly leaves an empty document. Acting on the first
  emitted `{}` and wiped the user's saved metadata the instant they selected
  it. `handleChange` now coalesces the pair in a microtask, and the component
  tracks what it last emitted so the parent echoing that value back is not
  mistaken for an external reset.

- Ace's own `github` themes fail WCAG AA on the gutter — `#AAA` on `#e8e8e8` is
  1.89:1, and the dark theme is 4.13:1. Folding needs the gutter, so the colors
  are overridden with palette tokens (5.82:1 and 4.88:1) rather than hiding it.

- `textInputAriaLabel` alone never reaches the DOM: Ace composes the label as
  "<label>, Cursor at row N" and only recomputes it when the cursor moves, so
  it is forced once on load. Until then the control's only accessible name is a
  position readout.

Testing is split because Ace's input path does not work under happy-dom at all
— `userEvent.type` reaches the textarea and produces no edit, so a test written
that way asserts nothing. Unit tests drive the editor instance through a shared
`src/test/aceEditor.ts` helper; the real keyboard behaviour (`{` then Enter
giving `{\n  \n}`) is asserted by Storybook play functions in Chromium.

`react-ace`/`ace-builds` are declared in `clients/web` rather than the root, per
the rule for a dependency that renders React components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 16:55:23 -04:00
Cliff Hall
b0ab6842aa
Merge branch 'v2/main' into v2/feat/1910-complex-request-metadata 2026-08-23 16:53:27 -04:00
Cliff Hall
527b3f63e2
Merge branch 'v2/main' into v2/fix/1914-surface-post-add-refresh-failure 2026-08-23 16:49:45 -04:00
cliffhall
ae9536b171 fix: log the rejected progressToken's type, not its value
The warning added one commit earlier echoed the rejected value. That is the
same leak class as the `servers/show` fix: `_meta` now carries arbitrary JSON,
so an invalid `progressToken` can be an object holding credentials — and this
logger is persisted by real clients (`clients/tui/src/logger.ts` writes it to
`~/.mcp-inspector/auth.log` via `pino.destination`, append mode).

Log `{ received: describeJsonType(value) }` instead — "object", "array",
"number". That is what makes the warning actionable; the value itself only
leaks.

Test asserts the record is exactly `{ received: "object" }` and that the
planted secret appears nowhere in the captured calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 14:19:48 -04:00
cliffhall
197e7aa401 fix(web): configure the client from the tracker at connect time
Review round 10 on #2091. Connecting is where the whole connection is
configured, and it read `server.settings` straight off the list entry —
so every connect undid what the tracker exists to preserve. A save that
landed while list reads were failing, including one made from the
settings modal for a server that was not connected at the time, was
silently reverted by the next connect: headers, roots, timeouts, the
Network log size, all rebuilt from the frozen entry.

`setupClientForServer` now resolves through `lastPersistedSettings`,
which covers every construction path (connect, reconnect, OAuth resume)
since they all pass through it.

The round-9 test also claimed to cover the log size and did not: it
asserted the settings object and the roots only, so a regression that
stopped restoring `maxFetchRequests` would have passed. It now asserts
the tracked fetch-log instance on both sides of the rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 14:12:50 -04:00
cliffhall
85e4a29990 fix: redact secrets nested inside metadata in servers/show
The redaction checked only top-level keys. That was sufficient while values
were strings — a secret could not hide beneath one — but #1910 lets a value be
an object, so `{ trace: { accessToken: "…" } }` printed in full: `trace` is not
sensitive, so the whole subtree went out with it. `servers/show` output is
meant to be safe to paste into an issue, so this is a regression this PR
introduced rather than a pre-existing gap.

`redactMetadataValue` walks the payload: a key matching `isSensitiveHeader`
replaces its entire value (so a sensitive key holding an object cannot leak
through its members either), anything else is descended into, and arrays are
mapped elementwise since objects inside them can carry sensitive keys.
Recursion is bounded — the payload came from `JSON.parse`, so it is a finite
tree with no cycles.

Tests cover a secret two levels under a non-sensitive key and one inside an
array element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 14:03:40 -04:00
cliffhall
db84823262 fix(web): apply the whole live surface, and track write outcomes per server
Review round 9 on #2091, all three findings.

Every live-apply now goes through one `applyLiveServerSettings`: the
settings object, the Network log size, and the roots diff together. A
close with a pending debounce applies the draft to all three, so
reconciling only `setServerSettings` when that save rejected left the
server advertising roots that were never persisted and the log sized for
them.

The "this server's draft is unpersisted" flag moves from a single id in
`App.tsx` into the tracker, which already orders writes by issue and
keeps state per server. One id could be overwritten by a failure for a
different server, or cleared by a stale success, either of which made a
close apply a rejected draft.

And when nothing has ever landed for a server, close falls back to the
empty settings the toggle rollback uses rather than to the draft — the
old fallback re-applied the very values that had just failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:56:36 -04:00
cliffhall
e9dc0e5e5f fix: drop a schema-invalid _meta.progressToken instead of sending it
`progressToken` is a reserved `_meta` member the spec constrains to
`string | integer` (`ProgressTokenSchema = z.union([z.string(),
z.number().int()])`). Every other value may be any JSON after #1910, which is
what makes this one key need its own check: before the widening a value was
always a string and therefore always valid, but now a float, an object or an
array can land there, and a conforming server would reject the whole request.

`progressTokenOf` only governed whether a progress callback was wired up, so a
bad value still rode out inside `_meta`. Sanitize in `mergeMeta` instead — the
single point every `_meta` flows through — dropping just the offending member
with a warning. The key is optional, so the request stays well-formed and
simply asks for no progress.

Note the exposure only exists with `progress: false`. With progress on (the
default) the SDK sets `onprogress` and stamps its own message id over
`_meta.progressToken`, so a caller's value never reaches the wire either way.
The new tests therefore run with progress disabled, which is the configuration
that can actually ship a bad token: five invalid shapes dropped (float, object,
array, null, boolean) with the sibling keys left intact, and string/integer
preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:36:45 -04:00
cliffhall
ba633fc8d2 fix(web): seed the settings draft from the tracker, and don't re-apply a rejected one
Review round 8 on #2091, both findings — the two remaining places the
stale `servers` entry and the unpersisted draft still leaked.

The modal draft was seeded straight from `servers`, and the draft is the
input to the *next* write: after a write landed whose list reload failed,
opening the modal and saving any unrelated field sent the superseded
value back to disk. It now seeds through the tracker, which falls back to
the entry when nothing has landed, so the ordinary case is unchanged.

`useSettingsDraft` also keeps the draft when a save rejects, and closing
the modal unconditionally pushed that draft into the live client — so the
client returned to the value that had just failed to reach disk, undoing
the rollback that reported the failure. The server whose last modal save
rejected is now remembered, and close applies what landed for it instead
(the roots diff and the fetch-log resize included). The flag clears on
the next save for that server that lands, and on the close that acts on
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:36:21 -04:00
cliffhall
38d2fd279d fix(web): apply settled settings to the live client, and reconcile on modal failure
Review round 7 on #2091, both findings.

Every settings continuation pushed through the `inspectorClient` it
closed over, and `onToggleConnection` rebuilds the client on each
(re)connect — so a write outliving a reconnect passed the server-id
guard and then wrote to a destroyed instance, while the replacement,
built from the stale list entry, kept the value the write replaced. The
live client is now mirrored in a ref alongside the active server id, and
all three continuations apply through it.

The modal save's failure path also stopped at taking itself out of
flight. A write that landed while it was pending had been told it was
not settled and applied nothing, so with the save failing there was
nothing left to apply the value that is actually on disk. It now
resolves and re-applies the baseline, the same as the toggle's failure
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:16:51 -04:00
cliffhall
0f628a8174 fix: address Copilot review on the _meta widening
- Widen the three `_meta` annotations the bulk pass missed, all of them
  return types rather than identifier declarations: `mergeMeta`,
  `aggregateListParams`, and the `tools/call` params object. The second was
  unsound in a way callers could see — it advertised
  `{ _meta: Record<string, string> }` while the runtime carried nested JSON.

- `normalizeStoredMetadata`: keep an explicit `null` from a legacy pair
  instead of folding it into `""` (only a missing `value` defaults now), and
  store through `Object.defineProperty` so a legacy `__proto__` key becomes an
  own property rather than hitting the prototype setter.

- Drop the unjustified `as unknown as` from the migration test; the legacy
  fixture is untyped and cast once where it crosses into the typed parser.

- The TUI settings test asserted only that the app mounted, so it could not
  have caught a pair array being forwarded as numeric `_meta` keys. The fake
  client now captures its constructor options and the test asserts the nested
  object arrives intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:13:04 -04:00
cliffhall
2fd6280cdb fix(web): reconcile a settled modal save, not just a settled toggle
Review round 6 on #2091. The modal writer recorded its write but threw
away the settled result, so the mixed-writer overlap was still broken:
the modal's flush is in flight with `paginatedLists: true` when the
sidebar toggle fails and rolls back to the stale entry's `false`, then
the save lands. It is the settled state, but nothing re-applied it — so
with the list reload also failing, disk held `true` while the override
and the live client sat on `false`.

Both writers now share the reconciliation as well as the record: a
settled write for the still-active server re-applies its value to the
override and the live client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:57:00 -04:00
cliffhall
04b5e70a53 fix(a11y): make the form error color meet WCAG AA in both schemes
Mantine's `--mantine-color-error` defaults to `red-6` in light and `red-8` in
dark. Both fail the 4.5:1 AA threshold that applies at the 12px size it renders
input error text: 4.48:1 on white, and 1.93:1 on our charcoal body. Override to
`red-7` (5.72:1) and `red-4` (6.44:1) — the latter matching the one-to-two-step
lift App.css already applies to the semantic status/log reds for dark.

Surfaced by the JsonObjectInput stories, which are the first in the repo to
render an input error state and so the first to put this in front of the
Storybook a11y gate. It is app-wide, not specific to that component — every
form error in the Inspector was affected.

It has to go through `cssVariablesResolver`: MantineProvider injects these
variables into a `<style>` block appended after the stylesheet imports, so a
`:root` rule in App.css loses to it on source order at equal specificity.

That resolver was duplicated in main.tsx and .storybook/preview.tsx and missing
from renderWithMantine, so the three could drift on any token they set. Extract
one `theme/cssVariables.ts` and use it at all three sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:56:13 -04:00
cliffhall
31feed970c fix(auth): make the stuck-lock guidance conditional, not an instruction (#2082)
The one genuinely new finding from Copilot round 9; the other four were
re-reports of rounds 7 and 8 against an earlier state of the files, verified
already fixed in e956d498 / edddc6c9.

This message has now been wrong in both directions. Round 5 replaced "it
expires on its own after 10s" — false whenever the same `rmdir` that blocked
us also blocks stale takeover, which reclaims through that identical call —
with "remove it by hand". That is false the other way: `proper-lockfile`
forwards whatever the filesystem returned, so a transient failure can clear,
another Inspector can legitimately acquire the path afterwards, and an
operator following the advice then deletes a *live* holder's lock. Same class
of hazard as the `ERELEASED` case in round 7: guidance that destroys the
exclusion of a process that did nothing wrong.

Nothing here can tell a permanent refusal from a passing one, so it no longer
claims to. It reports what happened, says the lock may or may not clear and
why, and states the two conditions the operator can check for themselves —
saves still failing, and no other Inspector running — rather than asserting
an outcome this code does not know.

Behavioural only in what it tells a human; the locking path is unchanged.
The test asserts both conditions are present and that the expiry promise is
absent, so neither blanket version can come back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:49:06 -04:00
cliffhall
a935de08dc fix(web): re-apply the write that settles after an overlapping one failed
Review round 5 on #2091, both findings.

The other overlap order was still wrong. Write 1 (`true`) is in flight
when write 2 (`false`) fails, so the rollback resolves to the stale
entry's `false` — correct at that instant, nothing had landed. Write 1
then lands `true` and only touched refs, so if its own list reload had
failed too, no render would ever correct the UI or the live client while
disk held `true`.

Writes now report both outcomes: `landed` returns whether this write is
the settled state for its server — recorded, with nothing later still in
flight — and `failed` takes a write out of flight so an earlier one that
lands afterwards settles. The toggle re-applies the settled value to the
override and the client, gated on its server still being active.

Serializing per server was the alternative and was not taken: it makes
the modal's debounced saves queue behind each other and changes the write
path to fix a UI-reconciliation concern, and it still could not order two
requests already in flight.

The override reset also moves from a `useEffect` to `useValueChange`,
which is what this repo requires for resetting state from render data —
the entry is referentially stable between reads, and keying on it alone
subsumes the persisted boolean and the server id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:40:15 -04:00
cliffhall
3a64ce7f4e refactor: move the JSON-object draft parser into utils
`react-refresh/only-export-components` rejects a non-component export from a
component module, so `parseObjectDraft` could not live beside
`JsonObjectInput`. It is a pure transform, which puts it in `utils` under the
`lib`/`utils` rule anyway.

Also render the metadata hint's inline literals through `<Code>` rather than
markdown backticks, which a Mantine `Text` shows verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:29:27 -04:00
cliffhall
edddc6c9cc fix(auth): hold the snapshot lock across the main lock's release (#2082)
Copilot review round 8 — two findings, both upheld. The first is a gap in
round 7's own fix.

**The snapshot was unprotected between two awaits.** Returning from
`withSecretFileLock(filePath, ...)` releases the main lock *before*
`handOffStagedSecrets` acquired the snapshot's, so a second startup could
take the main lock in that window, see the staged file unlocked, and adopt
and re-claim it — leaving this process reading a path that no longer exists.
Narrower than the race round 7 closed, but the same one.

`openSecretFileLock` is now split out of `withSecretFileLock`: it takes the
lock and returns its release, so a caller can hold one *across* another's
release. The claim acquires the snapshot's lock before returning — inside the
main lock — and the hand-off then runs outside the main lock, still holding
it. `withSecretFileLock` is a thin wrapper over the same function, so which
failures refuse and which degrade is decided in one place and the two entry
points cannot drift.

Locking the snapshot degrades rather than refusing (`null` → hand off
anyway): the exposure is one uniquely-named snapshot, which is a better trade
than refusing to migrate at all on a box that cannot lock.

**Both migration scans matched the lock directories themselves.**
`secrets.json.migrating-<pid>-<uuid>.lock` passes the plain prefix test, and
treating it as a snapshot is self-sustaining damage: the liveness probe asks
about a nonexistent `<name>.lock.lock` and so answers "not held", recovery
tries to hard-link a *directory* onto the secrets path, fails, and prints the
orphan warning. Because a liveness *check* never clears a stale lock
directory — only a would-be acquirer does — that false migration repeats on
every startup forever, including long after the real snapshot was recovered.
`isSnapshotName` now excludes `.lock` for both `anythingToMigrate` and
`recoverOrphanedSnapshots`.

New test verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:26:52 -04:00
cliffhall
b33532c024 feat: allow complex JSON values in request _meta
Closes #1910

`_meta` was typed `Record<string, string>` end to end in the Inspector —
on disk as a `{ key, value }[]` pair array, in the Server Settings form as
key/value rows, and in every request verb's per-call metadata parameter.
Nothing in the MCP spec restricts a `_meta` value to a string, and the SDK
models the field as a `z.looseObject`, so the restriction was ours alone.

Widen the whole path to `RequestMetadata` (`Record<string, JsonValue>`):

- Per-server default metadata is a JSON **object** on disk and in memory.
  The pre-#1910 pair array is still read (`normalizeStoredMetadata`) so an
  existing `mcp.json` keeps working; it is never written back, so a save
  migrates the entry.
- The Server Settings → Request Metadata section is a JSON editor
  (`JsonObjectInput`) instead of key/value rows — rows cannot express an
  object value. It keeps the typed text as the display source and emits
  only parsed JSON, the same draft split `SchemaJsonField` uses, so the box
  stays typeable; invalid text is flagged inline and not applied.
- The CLI stops flattening `--metadata` / `--tool-metadata` through
  `metaValueToString`. Those values were already JSON-parsed, so
  `trace={"id":"abc"}` used to reach the wire as the *string* `{"id":"abc"}`
  and now reaches it as an object.
- `servers/show` redaction replaces a sensitive key's whole value rather
  than a string, so a structured value cannot leak through its members.
- `progressTokenOf` narrows the reserved `progressToken` key back to
  `string | number` before it reaches the SDK; any other JSON still travels
  inside `_meta` but wires up no progress callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:23:40 -04:00
cliffhall
2aa97c1833 fix(web): let a successful read supersede a rollback, and scope it to its server
Review round 4 on #2091, three findings.

A rollback sets the override to a concrete value rather than clearing
it, so the effect that drops it could not key on the persisted boolean
alone: if a later read reported the value the override replaced — an
edit made outside the Inspector overtaking the write — neither that
boolean nor the server id changed and the UI stayed stuck on the
override. It now keys on the active `servers` entry too, which a
successful read always rebuilds.

The override is also a single app-wide boolean and the live client
belongs to whichever server is connected now, so a rejection arriving
after the user switched servers rendered one server's value on another.
The UI rollback and the client push are now applied only while the
failing write's server is still active; the toast is not gated.

The settings-modal writer had no coverage: `useSettingsDraft` is mocked
for `App.test.tsx`, so its `onPersist` never ran and dropping
`begin`/`landed` there would have gone unnoticed. A test now invokes the
callback the App handed the hook, lands a save, and asserts a failed
toggle rolls back to the modal's value.

Both new tests fail without their fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:16:22 -04:00
cliffhall
031991cc90 chore: three small cleanups from the v2.2.0 merge review
1. clients/web/.npmignore's explanatory comment still said the root "files"
   allowlist restricts publishing to build/ and dist/ and that everything else
   under clients/web "stays out regardless". #1934 added clients/web/static to
   that allowlist, so the claim was false — in a file that exists precisely
   because a packaging contract was misread once. Name static/ and say why it
   ships (read from disk at runtime by sandbox-controller.ts, at a path
   relative to the runner dir).

2. Replace the four unjustified `as unknown as Response` doubles in
   useServers.test.tsx with real Responses over real ReadableStreams: a
   null-body Response for the `!res.body` guard, a stream whose pull throws for
   the reader-rejects case, and pull-driven streams for the multi-frame and
   priming-comment cases (pull runs once per read, so the counting and the
   blocking second read behave exactly as the hand-rolled reader doubles did).
   The doubles are now structurally type-checked against the Response API.

3. scripts/smoke-web-app.mjs orphaned the MCP test server on a readiness
   timeout: startMcpServer() returned the child only once the announcement
   line matched, so a child that was alive but never announced left `mcpServer`
   unassigned, shutdown() could not stop it, and process.exit(1) left it
   holding its port. Publish the child to `mcpServer` the moment it is spawned,
   so every throw path is covered by teardown.

Closes #2000

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:02:55 -04:00
cliffhall
b35db18fe8 Merge remote-tracking branch 'origin/v2/main' into v2/feat/2082-file-secret-store-lock 2026-08-23 11:56:05 -04:00
cliffhall
e956d49833 fix(auth): lock the snapshot during hand-off, keep quiet on ERELEASED (#2082)
Copilot review round 7 — two findings, both upheld.

**An in-progress migration snapshot could be adopted by another startup.**
The fast path counts every `*.migrating-*` as work and
`recoverOrphanedSnapshots` adopts them — including one whose owner is still
reading it. It link/unlinks the snapshot back to the live path and re-claims
it under a new name, the owner's hand-off then fails `ENOENT`, and if the
adopting process exits before copying, that healthy session starts with none
of those secrets, recoverable only on some later run.

Fixed the second way the reviewer suggested, not the first. Holding the main
lock across the hand-off also closes it and was tried — it broke "claims the
file atomically, so a later write is not deleted", because it blocks every
ordinary writer for the whole migration and fails their save past the retry
budget. That is a worse regression than the race, since #1950 guarantees a
write completing after the claim survives. The existing test caught it.

So the hand-off takes a lock on the **snapshot** instead, and
`recoverOrphanedSnapshots` skips an orphan whose lock is held. Liveness comes
from the lock rather than from the pid already in the filename: a pid both
outlives its process and recurs — pid 1 on every container start, which
#1950 documents as the reason the name carries a nonce — whereas a lock
expires by itself if its owner dies, so a genuinely abandoned snapshot
becomes adoptable with nothing to clean up. `isFileLockHeld` answers `false`
when it cannot tell, since the alternative to a wrong `false` is never
recovering an abandoned file.

**A compromised lock arrives as `ERELEASED`, and we told the operator to
delete the lock.** `setLockAsCompromised` marks the lock released and drops
its registry entry *before* calling `onCompromised`, so `release()` answers
`ERELEASED` without touching the filesystem — and the directory at that path
is by then the **winner's live lock**. "Remove it by hand" would destroy the
exclusion of a process that did nothing wrong. Handled separately and
silently; `onCompromised` has already reported what happened. A conditional
rather than an early return, since a `return` inside `finally` discards
whatever the body was returning or throwing.

Both new tests verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:54:47 -04:00
cliffhall
ce29d8273d fix(web): resolve the rollback baseline at failure time, not at issue
Review round 3 on #2091. The baseline was still captured when the write
went out, so overlapping toggles reproduced the original bug through the
new machinery: toggle 1 PUTs `true` while its reload is pending, toggle 2
reads the baseline and sees `false`, toggle 1 lands `true`, toggle 2
fails — and its catch pushed the `false` it had closed over, contradicting
both disk and the hook.

`resolve` now reads the record and the list live and takes no fallback,
returning `undefined` when neither has anything to say, so calling it in
the failure handler answers for the state at failure time. Both the
value being written and the rollback go through it, each at the moment
it is needed. The hook says explicitly not to hoist the result.

The regression test drives the exact order — issue 1, issue 2, land 1,
fail 2 — with deferred promises rather than timing, and fails on the
issue-time capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:52:16 -04:00
cliffhall
c67ea8a002 Merge remote-tracking branch 'origin/v2/main' into v2/fix/2089-pagination-rollback-baseline 2026-08-23 11:42:34 -04:00
cliffhall
7fa42299c7 fix(web): order overlapping settings writes by issue, not by completion
Review round 2 on #2091. Recording on completion made the *last to
finish* the winner, and `updateServerSettings` waits for a list read
after its PUT — so an older write with a slow read finishes last and
would overwrite the record with a value a newer write had already
replaced on disk. Both writers can overlap: two quick toggles, or the
settings modal's debounced flush.

A write is now announced with `begin` when it is issued and confirmed
with `landed` when it completes, and a confirmation is dropped if a
later-issued write for that server has already confirmed. The high-water
mark is kept apart from the record so a straggler cannot resurrect a
record a fresh list read dropped.

Issue order is a proxy for disk order rather than a proof of it — HTTP
promises nothing about two in-flight PUTs — but it is the best signal on
this side until the servers route returns a revision to order by. Noted
on the hook alongside the other consequence of that gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:34:20 -04:00
cliffhall
09e1564672 fix(auth): refuse on a stuck lock, capture identity at the mkdir seam (#2082)
Copilot review round 6 — five findings (two inline, three suppressed), all
valid.

**A stale lock that cannot be cleared caused unlocked writes.** `acquireLock`
does not only *create* directories: on finding a stale one it removes it and
retries, and that removal can fail — `ENOTEMPTY` for a lock with anything
inside it, `EACCES`/`EROFS` for one we may not touch. Those surface as
ordinary non-`ELOCKED` errors, which the probe read as "locks do not work
here" and degraded on. So every Inspector on the box quietly bypassed the
*same* stuck lock and raced its writes — while the release-failure message
was telling the operator saves would keep failing until they cleared it.

`isStuckOrHeld` now discriminates on **whether the lock directory exists**
rather than on an errno taxonomy: if it is there, something holds it and we
refuse; if it is not, we genuinely could not create one and degrading is the
documented trade (#1848, #1905). Reading the state the decision is about
avoids enumerating error codes per platform — and an enumeration is exactly
what let `ENOTEMPTY` through.

**Identity was captured after the acquire promise settled.** That span
includes the library's own `utimes`/`stat` probe, so a waiter replacing our
directory inside it would have us record *the winner's* identity as our own,
after which the removal guard would accept and delete their lock. It is now
recorded in the `mkdir` callback — the moment the directory becomes ours, and
synchronous with respect to this process, so there is no window.

**The expiry advice was still wrong for `EACCES`/`EPERM`/`EROFS`.** Since the
guard `stat`s before removing, everything reaching the release catch is an
`rmdir` that was refused — and stale takeover reclaims through that same
`rmdir`. The `ENOENT` arm was unreachable for the same reason, so the branch
was dead code: removed, and the advice is now unconditional.

**`proper-lockfile` was missing from `NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE`.** A
fourth externalization surface — the tsup lists configure the production
bundles, not `vite dev`, whose dep scanner would otherwise walk its
CJS/`graceful-fs`/`signal-exit` graph. Added, and `vite-base-config.test.ts`
now asserts it along with `chokidar` and `@napi-rs/keyring`, which were also
absent from that assertion.

**A test was named for something it did not exercise.** "does not treat an
unlistable directory as an empty one" was driving `ENOTDIR`, which is
deliberately treated *as* the fresh-install case, so the actual new behaviour
was untested. Split in two: one for `ENOTDIR`, and a real `EACCES` one using
a `--x` directory — `readdir` denied, a known name still reachable, which is
the asymmetry that makes "unlistable" different from "empty". Driven with a
real mode rather than a stub, since `vi.spyOn` cannot redefine an ESM
namespace export; skipped as root and on Windows, where the mode would not
bite and the test would assert nothing.

Both new tests verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:31:37 -04:00
cliffhall
e359368892 fix(web): key the rollback baseline per server, and pair it at completion
Review round 1 on #2091. A single record slot let a save on one server
discard the baseline a later failed write on another still needed — the
settings modal opens for any server, connected or not, so that sequence
reproduces #2089 through the very mechanism meant to prevent it. Records
are now kept per server id.

The record also paired with the `servers` entry captured when the write
was *issued*, so a list read landing while the write was in flight was
mistaken for one that happened after it and discarded a live record. It
now pairs with the entry as of completion, read through a ref (mirrored
in an effect — writing a ref during render is a `react-hooks/refs`
error). The residual — a read whose response predates the write but
arrives after it — needs a revision or ETag `useServers` does not
expose, and is documented on the hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:18:34 -04:00
cliffhall
b159c75b88 fix(auth): guard every lock removal, not just release, and stop promising expiry (#2082)
Copilot review round 5. Both comments upheld; the first is a real hole in
round 4's own fix.

**The ownership guard was in the wrong place, and overclaimed.**

Round 4 checked ownership and then called `release()`. Two problems:

- Check-then-act. The `stat` and the `rmdir` were separate async steps, so a
  waiter could replace the directory in between and we deleted the winner's
  fresh lock anyway — the very thing the check was added to prevent.
- Skipping `release()` on a mismatch left proper-lockfile's record registered
  in its `locks` map, and its `signal-exit` handler `rmdirSync`s every
  registered lock with **no ownership check of its own**. An exit during that
  window deleted the winner too.

The guard now lives in `options.fs`, which is the single seam *both* removal
paths route through — `removeLock` on release, and the exit handler. It
refuses to remove a directory whose inode and birth time are not the ones
recorded at acquire, and reports the refusal as success so the library's
bookkeeping forgets the lock either way (leaving it registered is what hands
the exit handler a record pointing at the winner's directory).

The check is a `statSync` immediately followed by an `rmdirSync`, with no
`await` between them, so nothing in this process can interleave. **It still
does not close the race** — it is check-then-act across processes, which needs
the compare-and-swap Node does not expose — and saying it "closes the
destructive half outright" was wrong. file-lock.ts, README.md,
specification/v2_servers_file.md and AGENTS.md now all say it narrows the
window rather than closing it. AGENTS.md carries the negative instruction
alongside round 4's, since this is the third round spent on an overclaim.

**"It expires on its own after 10s" is false for the ENOTEMPTY case** the new
test exercises. Stale takeover reclaims through the same `rmdir`, which also
cannot remove a non-empty directory — so nothing clears it, and every later
save fails ELOCKED against it. `releaseAdvice` now branches: by-hand removal
for ENOTEMPTY, expiry for everything else.

Tests: a real child process demonstrates the exit handler removing a
replacement lock (with an **empty** directory — a non-empty one makes
`rmdirSync` fail ENOTEMPTY, which is how the first draft of this test passed
for the wrong reason). The release-failure test now asserts the message says
"by hand" and does not promise expiry. The shim's `rmdirSync` arm carries a
justified `v8 ignore`: it is reachable only from a real process exit, and its
logic is `removeIfMine`, covered through the async arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:10:48 -04:00
cliffhall
0bcb7ec78a fix(web): roll a failed settings write back to what actually landed
The pagination toggle read its rollback baseline off the `servers` entry,
which only advances on a successful `GET /api/servers`. Once a list read
fails that entry describes disk as it was before every write made since,
so the #1721 rollback reverted to a value nothing on disk holds — and
clearing the optimistic override fell back to the same stale entry, so
the UI was wrong from both directions.

Remember the last settings write known to have landed (a new gated hook,
`useLastPersistedSettings`) and roll back to that instead, keyed to the
`servers` entry it was derived from so a fresh list read supersedes it.
Both settings writers — the toggle and the settings modal's debounced
persist — feed the one record.

Closes #2089

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 10:57:15 -04:00
cliffhall
91e1392c1f fix(auth): stop deleting the winner's lock after a stale takeover (#2082)
Copilot review round 4 — four comments, all the same finding against the four
files carrying the claim, and it was more than a wording problem.

The comment said a holder whose lock was replaced is told. Checked against
proper-lockfile@4.1.2, that is false in the case that actually happens:

- `updateLock` compares mtime only on its refresh tick — `stale / 2`, so 5s
  here — while an ordinary mutation is a read, an scrypt derivation and an
  atomic write, comfortably under a second. The tick never runs, so nothing
  fires.
- `release` → `unlock` → `removeLock` is an unconditional `rmdir` with **no
  ownership check**. So the compromised holder goes on to delete the
  *winner's* lock on the way out, ending the winner's exclusion too: one
  compromised writer silently becomes two unprotected ones.

The second half is a correctness bug, not a documentation defect, and it is
fixable here even though the takeover race itself is not.

`withSecretFileLock` now records the lock directory's inode and birth time at
acquire and re-checks them before releasing. On a mismatch it **declines to
release** — leaving the new holder's lock alone costs nothing, ours is
already gone — and warns.

Inode and birth time rather than mtime, deliberately: both survive the
library's own `utimes` refresh, so a lock held past one tick is not accused of
being compromised, and both change on a delete-and-recreate, which is exactly
the event to detect. Where a filesystem reports neither, the two reads agree
and the check concludes "ours", degrading to the library's unaided behaviour
rather than to a false alarm. An unreadable baseline at acquire likewise
answers "ours" — with nothing to compare against, accusing a healthy lock
would be worse than releasing it.

Detection is therefore **best-effort**, and file-lock.ts, README.md,
specification/v2_servers_file.md and AGENTS.md now all say so rather than
promising the loser is told. AGENTS.md carries the negative instruction too,
since this is the second round spent on the same overclaim.

Tests: "does not delete the winner's lock after being taken over" replaces the
lock inside the body and asserts the replacement survives with the *same*
inode and birth time — it was left alone, not deleted and recreated. It waits
for nothing, being the fast case the library's tick misses. Verified by
mutation: forcing `stillOurs` to always answer true fails it. A second test
covers a release that fails while the lock genuinely *is* ours (a stray file
makes `rmdir` fail ENOTEMPTY), so the ownership check cannot swallow a real
release failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 10:51:40 -04:00