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>
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>
`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>
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>
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>
- `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>
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>
`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>
- `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>
`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>
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>
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>
Prettier prefers `_x_` over `*x*`; two spots in the Concurrency paragraph this
PR rewrote were left in the other style. `specification/` is not covered by
any `format:check` glob, so nothing flagged it — found by running prettier
against the file by hand while editing it.
Cosmetic only.
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>
It has shipped. `core/mcp/import/strategies.ts` carries a `claudeDesktop`
strategy that reads `claude_desktop_config.json` from the documented
well-known paths and merges it in, alongside Cursor, Cline and VS Code — so
listing it as a follow-up describes the spec's original scope rather than the
code, and reads as a gap to anyone checking what is missing.
Text only; no code path touched.
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>
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>
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>
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>
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>
`RecordedRequest.metadata` was still `Record<string, string>`, so the recorder
advertised a string-only contract it no longer honors. The new CLI tests assert
objects and arrays against it and passed only because `toEqual` does not
enforce the declared type.
Typed as `Record<string, unknown>` rather than core's `RequestMetadata`:
test-servers compiles standalone (`tsc -p test-servers`, NodeNext, no `paths`),
so it has no `@inspector/core` alias to import through.
Also update `progressTokenOf`'s doc comment, which still claimed an invalid
token "reaches the wire inside `_meta`" — untrue since mergeMeta started
deleting it. It now names the split: progressTokenOf governs callback
correlation, mergeMeta governs wire sanitization, both via isProgressToken.
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>
`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>
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>
The field table said this payload is "merged into every outgoing request",
which is true of web and the TUI but not the CLI — it never passes the
persisted value to its client, so the setting is inert for `--cli` runs
(#2093). Scope the claim and point at the per-invocation `--metadata` flag as
the CLI workaround, so a CLI user isn't told a setting works when it doesn't.
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>
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>
- 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>
Copilot's suppressed round-3 comment is right. The attempt-counted loop read
the output and then slept, so nothing ever observed the last interval: an
announcement arriving at 29.9s of a 30s budget was reported as a timeout, and
a spawn error or early exit landing in that window was misattributed the same
way. Inherited from the original smoke, but wrong either way.
Replace it with a deadline loop that re-reads state after every wait, the last
one included, and clamp the final wait to the deadline so the budget is a real
bound rather than one poll longer.
The regression test sets pollMs === timeoutMs, which makes the distinction
deterministic rather than a race: the attempt-counted loop gets exactly one
check, at t≈0 before the child has finished starting, then throws; the deadline
loop re-reads after the wait and sees the announcement. The child announces
immediately, ~2s inside the window, so a loaded runner can't flip it. Verified
both directions — the test fails against the old loop and passes against the
new one — and `npm run test:scripts` was repeated to confirm it is stable under
the full 116-test parallel run (an earlier margin-based version of this test
was not).
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>
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>
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>
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>
Two review findings from round 2, both correct.
1. The regression suite killed its child only after the assertions. If
`assert.rejects` or either assertion failed, execution never reached the
kill, leaving a 60-second child attached to the runner — a suite guarding a
process leak would leak one itself, precisely on the run where the
regression had come back. Teardown is now registered from `onSpawn` via
`t.after`, so it runs on every path out of every case. That is the helper's
own ownership rule applied to the tests; killing an already-exited child is
a no-op, so the early-exit cases pay nothing for it.
2. Document the new shared helper, per the maintenance rules. AGENTS.md's
scripts/lib tree entry now carries announced-child.mjs and what its contract
buys, and the smoke:web:app paragraph records why the spawn/readiness step
moved out of the smoke (same reason teardownWebServer left prod-web-server's
stop()). README's test:scripts row names the new suite alongside
resolve-node-bin's.
Not mirrored into .github/copilot-instructions.md: that file is a distillation
of the rules a reviewer cites against a diff, and deliberately excludes the
project-structure tree and tooling inventory.
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>
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>
`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>
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>
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>
Copilot's review is right that the fix was unproven: smoke:web:app's happy path
always receives the announcement, so `npm run ci` never exercised a child that
stays alive *through* the readiness timeout — exactly the case that orphaned a
live server holding its port. A regression there would be silent in CI and
would surface only as a stray process on a later run.
Extract the spawn/readiness ownership into scripts/lib/announced-child.mjs
(the same move prod-web-server.mjs made for teardownWebServer, and for the same
reason), and add scripts/lib/announced-child.test.mjs under `test:scripts`.
The tests drive real `node -e` children rather than spies, so the assertion is
that a real process is reachable and killable, not that a mock was called: the
timeout case asserts the child was published before the wait, is still alive
when the throw lands, and is actually stopped by kill. The early-exit and
spawn-failure paths are covered alongside it, plus the both-streams scan.
smoke-web-app.mjs keeps its behavior — startMcpServer() now delegates the
spawn and publishes the child via `onSpawn`.
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>
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>
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>
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>