Commit graph inspector/clients/web
Author SHA1 Message Date
Cliff Hall
d186f09e6c
Merge branch 'v2/main' into v2/chore/2000-three-small-cleanups 2026-08-23 17:21:23 -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
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
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
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
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
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
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
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
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
99a194ca69 chore(lint): fail lint on warnings (--max-warnings 0)
`lint` failed only on errors, so a warn-level rule was a channel nothing
consumed. `react-hooks/exhaustive-deps` reported two stale-closure
`useCallback` deps in App.tsx on every run, `npm run ci` passed anyway,
and the defect was caught only by a review round on #2076.

Add `--max-warnings 0` to all six lint scopes — each of
clients/{web,cli,tui,launcher}'s `eslint .`, plus the root's `lint:core`
and `lint:shared` — so a warning fails `lint`, and therefore `validate`
and `ci`. All six were already warning-free, so no suppressions, rule
downgrades, or ignore-list widening were needed.

Also promote `react-hooks/exhaustive-deps` to `error` in both React
scopes (web, tui). The flag makes severity irrelevant to the gate; it
does not make it read correctly in an editor.

Document the invariant in AGENTS.md as a new "Lint has no warning tier"
section, note it in the README beside the type-aware-linting paragraph,
and mirror it into .github/copilot-instructions.md.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 10:47:29 -04:00
cliffhall
a859093bd3 Merge remote-tracking branch 'origin/v2/main' into v2/feat/1950-file-secret-store
Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	README.md
#	clients/web/src/App.tsx
2026-08-23 01:40:01 -04:00
cliffhall
e5c254dbea perf(web): refresh the storage descriptor once per import batch
`useImportClientConfig` applies every addition and conflict sequentially, so
wrapping its per-entry `onAddServer`/`onUpdateServer` in `refreshingPersist`
issued one `/api/config` per imported server. On an encrypted file store each
of those authenticates the whole file with a scrypt derivation (N=16384), so a
20-server import paid 20 serialized KDFs and round trips for a descriptor that
cannot change more than once.

Refresh on close instead: the modal must be dismissed before the settings
footer that reads the descriptor can be reached, and closing also covers a
partially-failed batch.

`ServerImportJsonModal` is left wrapped — it adds a single server, so its one
refresh is already once per batch.

Closes #1950 (review round 26)

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 00:10:10 -04:00
cliffhall
beaaad4982 fix(apps): make a covered elicitation dialog inert
Only the top modal owned the focus trap, but every lower `Modal.Content`
remained an exposed `aria-modal` dialog with reachable controls and a live
iframe — so browse navigation could land in a dialog that is visually covered
and that itself claims the rest of the page is inert.

Non-top contents are now `inert`: out of the accessibility tree and out of
focus order, without unmounting, so each app keeps its bridge and its handshake
and is live the moment it becomes top.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 00:02:37 -04:00
cliffhall
1202bd8663 fix(web): list refreshInitialConfig in both persist callbacks' deps
Both `useCallback`s that wrap a persist call in `refreshingPersist` captured
`refreshInitialConfig` without listing it. `useInitialConfig`'s `refresh` is
not referentially stable — it is a `useCallback` over `load`, whose deps are
`[base, authToken, doFetch]` — so once any of those changes, the pagination
save and the server removal keep invoking the obsolete closure and the
secret-storage footer can go stale.

`react-hooks/exhaustive-deps` reported both, but the rule is warn-level and
`lint` does not fail on warnings, so `npm run ci` stayed green. These two were
the only warnings in the entire web client.

Closes #1950 (review round 25)

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-22 23:52:21 -04:00
cliffhall
356e8eaa8a fix(apps): read the settled sandbox URL from a ref at client construction
The previous round's `await` did not do what it claimed. `setupClientForServer`
is synchronous and memoized, so a caller that awaited the config resumed with
the `sandboxUrl` captured by the render it STARTED in — undefined, on exactly
the load the wait was added for. The deep-link one-shot guard then prevented a
retry, so that connection silently lost the capability despite having a
sandbox. The OAuth-callback path did not wait at all.

Construction now reads a ref written every render, so it sees the current
value whichever entry point reached it, and the OAuth path waits like the
connect path does. The wait still matters — it is what makes an absent value
mean 'confirmed absent' rather than 'not known yet' — but the ref is what makes
the value read be the settled one.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 23:39:08 -04:00
cliffhall
af3d940e78 fix(auth): stop a config-only PUT deleting stored secrets (#2084)
Round 24 produced no inline comments again. Of the three suppressed items, one
is a pre-existing data-loss bug that predates #1950 entirely.

**Editing a server's config deletes its stored OAuth client secret — from the
keychain, not only from the stores this PR adds.** Filed as #2084 and fixed
here, since the review surfaced it and these lines were already changing.

`useServers.updateServer` deliberately sends `{ id, config }` with no
`settings`, so a config-only save cannot wipe persisted settings. The route
reads that as `preserve` and re-derives settings from the *on-disk* entry —
which by #1356's design no longer holds the secrets. The submitted secret set
is therefore empty, `expectedSecretFields` always lists the OAuth slot, and
the reconcile deleted a value the user never touched. The rename branch was
given `mergeRenameKeychainSecrets` for exactly this reason; the in-place
branch never got the equivalent.

The first fix was too broad — skip obsolete deletion whenever the intent is
`preserve` — and an existing test caught it immediately: stdio `env` is part
of `config`, so a config-only PUT that drops a key really has retired that
secret. Obsolete detection is per-field now:

- **`env:` fields** are implied by the entry's shape, so the new entry no
  longer expecting one is proof it was retired.
- **The OAuth slot** is implied by nothing, so absence counts as "cleared"
  only when the caller spoke about settings at all.

Both directions are tested, and the pre-existing env-key reconcile test still
passes.

Also:

- `refreshingPersist` moves to `lib/`. The combinator computes nothing, but
  what it exists to do is sequence side effects, which is the `lib` half of
  the repo's split; I judged it the other way on the grounds that the
  function is a pure higher-order one, and the rule is about what a module is
  *for*.
- `SecretStorageInfo.plaintext` is documented as file-only and *omitted*
  elsewhere rather than "always false", which is what the producer does and
  what `usableSecretStorage` enforces — the old wording invited a consumer to
  build the exact shape the web client discards.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 23:30:17 -04:00
cliffhall
ce263366b6 fix(apps): tear the app down when the server cancels the elicitation
The direct server→client `elicitation/create` handler ignored its request
context, so `notifications/cancelled` aborted `ctx.mcpReq.signal` and nothing
was listening: the modal and its bridge outlived the cancelled request and
could still answer work the server had abandoned.

The handler now threads that signal into `enqueuePendingElicitation`, which
already wires it to both answer surfaces — the native queue entry and the app
renderer. The task-augmented branch deliberately does not use it: that request
is answered immediately with a `CreateTaskResult`, so its lifetime is the
task's, which carries its own abort.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 23:19:23 -04:00
cliffhall
de84b67046 fix(auth): stop blaming the passphrase for a corrupt payload, and refresh the stale docs
Round 23 produced **no inline comments** — nothing wrong in the code it had
not already seen. The five suppressed ones are all in unchanged code: one real
misdiagnosis and four pieces of drift.

**A decrypted-but-corrupt payload was reported as a wrong passphrase.** By the
time `JSON.parse` runs, GCM has verified the tag — so the passphrase is
*proven correct*, and a `SecretFileKeyMismatchError` there tells the user to
restore a secret they never lost. `asSecretMap` was already outside that catch
for exactly this reason; `JSON.parse` was not, so the same class of mistake
survived one line above the fix for it. The decrypt now has its own narrow
catch and everything after it reports a corrupt file.

The test fixture could not express this case — it `JSON.stringify`s its
payload, so it can only produce valid JSON. Split into a raw-plaintext variant
so a fixture can carry bytes that are authentic under the passphrase and still
unparseable, which is the only shape that reaches the branch.

The rest is documentation that had drifted from the code it describes:

- `handOff`'s doc still described the re-read-and-compare protocol that the
  atomic claim replaced two rounds ago — an obsolete concurrency invariant is
  worse than none, because the next reader takes it as the contract.
- The spec still described that same hand-off, and still called Client and
  Server Settings "the only two dialogs that accept a secret". Both now match
  the code, and the dialog count is called out as load-bearing: the one that
  was missing from it was the one taking secrets with no disclosure at all.
- The `void load()` in `refresh` carries the justification the repo's
  no-floating-promises rule requires — `load` owns its failures, and `refresh`
  is deliberately synchronous because `refreshingPersist` calls it from a
  `finally`.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 23:08:52 -04:00
cliffhall
0f3ee265fc fix(apps): bind app elicitations to the connection that made them
Two of the three findings; the third is answered in the PR thread.

**A replaced client could still enqueue.** The one-shot sweep only rejected what
was already queued, but the outgoing `InspectorClient` disconnects
asynchronously and can enqueue during its own teardown — and that late entry
would be rendered by a factory bound to the REPLACEMENT client, reading its
resource and answering through a different server. Requests now belong to a
session: each constructed client gets one, closing it rejects that session's
entries AND refuses anything it queues afterwards, and another session's
entries are untouched.

**The advertisement no longer has to guess.** Whether a sandbox exists is only
known once `/api/config` resolves, and the answer is baked into the client at
construction — guessing "available" over-claims, guessing "unavailable" strands
the whole session on the native form despite having a sandbox. The connect path
now awaits that fetch (already in flight since mount, so no human ever waits;
it only orders a deep-link auto-connect racing the same page load), after which
an absent `sandboxUrl` means confirmed-absent.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 22:55:43 -04:00
cliffhall
4ce0643ac1 fix(auth): refresh in finally, bound the KDF parameters, cover the last two writers
Round 22. Three findings, all in code I wrote, and the first two are both
cases of a stated rationale being wrong rather than a line being wrong.

**`refreshingPersist` skipped the refresh on failure, and I had argued for
that explicitly.** The doc and its test both said "a persist that threw did
not write, so there is nothing new to describe". That is false for this write
order: both persistence paths write the secret store *before* the file, so a
rejected disk write can follow a `set` that has already upgraded
`secrets.json` from plaintext to encrypted. The wrapper then left the footer
describing a file that no longer exists in that form — the stale-descriptor
bug it exists to prevent, reached through its own error path.

It refreshes in a `finally` now. The asymmetry is what settles it: refreshing
after a failure that changed nothing costs one idempotent GET, while not
refreshing after a failure that changed something leaves a security statement
wrong until reload. The test asserting the old behavior is inverted, with the
reasoning recorded so it is not "corrected" back.

**The KDF parameters were validated for well-formedness, not for cost.**
scrypt's work is proportional to `N * r * p`, and all three come off disk — so
a file declaring a modest `N` with `p: 1_000_000` burns unbounded CPU on every
read *and* on merely describing the store for `/api/config`, which is a page
load. Anyone who can write that file can already read its secrets, so this is
not a privilege boundary; it is a footgun and a hang, and the fix is free
because this build writes exactly the constants it now enforces as maxima.

**Two secret-store mutations still bypassed the refresh.** The pagination
toggle resends the server's rehydrated secrets, and deletion sweeps them —
both are writes, and a write is what performs the pending upgrade. Routed
through the same wrapper as the rest.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 22:46:42 -04:00
cliffhall
8f14d371ad fix(auth): land the round-20 UI wiring that never applied, and nonce the staging name
Three of round 21's four findings say the same thing: **the UI half of round 20
was never applied, and I reported it as done.**

The edit script aborted partway on a regex error, after writing one file and
before writing the other two. So `ServerConfigModal` gained the footer prop
but `App.tsx` never passed it — the footer was `undefined` in production and
rendered nothing, visible only to the unit test that passes a descriptor
directly — the extra refresh call sites never landed, and the footer's doc
comment still claimed two secret-entry dialogs while the code had three.

`npm run ci` passed throughout, because every test I added exercises the piece
that *did* land. This is the same failure as round 17's uncommitted files with
a different cause: I verified intent (green gate, clean typecheck) instead of
the artifact. Each edit now asserts it changed the file, and the result is
grepped afterwards — the check costs a second and is the only one that
describes what is actually on disk.

Landed now, and verified in the files rather than inferred:

- `App.tsx` passes `secretStorage` to `ServerConfigModal`, so the disclosure
  the previous commit claimed to add actually renders.
- Every secret-bearing write refreshes the descriptor — config submit and both
  import flows, not just the two settings saves. Any of them can perform the
  pending plaintext-to-encrypted upgrade, after which the footer was reporting
  "Plaintext file" until reload.
- The footer's doc names all three dialogs. The wrong count there was not a
  description of the bug, it *was* the bug: the omitted dialog was the one
  taking secrets with no disclosure.

Also, the fourth finding: **the staging name was reusable across restarts.**
`recoverOrphanedSnapshots` deliberately leaves an orphan when a live
`secrets.json` also exists, and a pid-only name repeats (pid 1 on every
container start) — so the next claim would `rename` straight over that orphan
and permanently discard secrets it may uniquely hold. A per-attempt nonce
makes a staging path unrepeatable.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 22:30:21 -04:00
cliffhall
d97c9bd391 fix(tasks): tie a receiver task's answer collection to its cancellation
`tasks/cancel` marked the task cancelled and rejected its payload, but left
whatever was collecting the answer running — the native pending-request entry,
and now an app-rendered elicitation's renderer and bridge. A modal therefore
outlived the task it belonged to, and an answer arriving afterwards re-settled
it, overwriting `cancelled` with `completed`: a task the server had been told
it cancelled.

Each receiver task now owns an `AbortController`, aborted by `tasks/cancel`
and by session teardown. The task-augmented elicitation branch passes its
signal to the app attempt and wires it to the native queue entry, so both
answer routes are torn down the same way. Both settle helpers return early on
a terminal status, so a late answer through either route cannot resurrect the
task.

The overwrite predates the app path — a native answer after a cancel did the
same thing — so the guard fixes both.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 22:29:18 -04:00
cliffhall
15e9260484 fix(auth): make the durability guard provenance-aware, and harden the claim
Round 20, and almost all of it is fallout from round 19's two changes. Both
were right in direction and wrong in the detail, in the same way: each solved
its case by treating a broad thing as a proxy for a narrow one.

**The durability guard wrote newly entered secrets to disk in plaintext.**
Preserving legacy plaintext for a session-scoped store is correct — stripping
it moves the only durable copy into RAM. Using *the whole submitted entry* as
the proxy for "legacy" was not: it turned
`MCP_INSPECTOR_SECRET_STORE=memory` into "write every new secret to
`mcp.json` in the clear", while the footer for that very store says secrets
are written nowhere. A security regression introduced by a data-loss fix, and
the same overshoot in `client.json`.

Provenance decides now. A secret field is kept on disk only when it was
already there, unchanged, in the file being overwritten; a newly entered or
changed value is stripped and lives in the session store, which is exactly
what the footer promises. Where provenance cannot be established — an
unparseable prior file — the value is treated as new, because writing a
secret to disk on a guess is the failure that matters.

**The atomic claim was not as safe as its comments said.** Four ways:

- A crash between the claim and the copy left only
  `secrets.json.migrating-<pid>`. The next run checked the canonical path,
  found nothing, selected the keychain, and every stored credential vanished
  with no warning. Orphans are now adopted at startup.
- Restoring used `rename`, which on POSIX **replaces** its destination — so
  the restore path could overwrite a `secrets.json` a concurrent writer had
  recreated, destroying the later successful write that claiming exists to
  protect. The comment beside it asserted the opposite. `link` + `unlink` is
  the no-clobber primitive this needed.
- Every claim failure was read as a lost race. `EACCES`/`EROFS` leave the
  file exactly where it is, and returning quietly selected the keychain while
  file-backed secrets sat there invisible. Only `ENOENT` is benign now.
- A failed cleanup left a plaintext-capable snapshot on disk with a silent
  catch, while the app reported the keychain as the only store. It names the
  path now.

**A third secret-entry surface had no disclosure at all.** `ServerConfigModal`
takes stdio `env` values, which are extracted into the store exactly as
Server Settings' are — and the footer's own doc comment claimed there were
only two such dialogs. It has the footer now, and every server write that can
carry a secret (config submit, both import flows) refreshes the descriptor,
not just the settings save.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 22:10:07 -04:00
cliffhall
d90c51a04c fix(apps): close the three gaps the review flagged as previously missed
**The task-augmented inbound branch bypassed the routing.** A server→client
`elicitation/create` carrying `params.task` answers immediately with a
`CreateTaskResult` and settles the TASK when the user answers, so it cannot
ride `enqueuePendingElicitation` — and it was therefore never offered to an
app, even with a valid `_meta.ui.resourceUri`. It now makes the same attempt,
completing the task with the app's result and falling back to the native queue
otherwise; an abort fails the task rather than reopening it natively. The two
task-settling closures are extracted so both answer routes settle it
identically.

**The web client advertised the capability without a sandbox.**
`useInitialConfig` leaves `sandboxUrl` undefined while `/api/config` is in
flight and when the sandbox controller could not start, but the renderer was
supplied unconditionally — so the client claimed it could host an app in
exactly the window where it could not. The option is now gated on a resolved
sandbox URL: such a connection behaves like the CLI/TUI, native queue and no
claim made.

**Concurrent modals each owned the keyboard.** Every open `Modal.Root`
installed its own focus trap, Escape handler and overlay, so the traps fought
and one Escape could dismiss more than one pending request. Only the topmost
owns them now; the rest stay mounted, keeping their bridges and handshakes,
but inert to the keyboard.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 22:05:15 -04:00
cliffhall
5ef3572578 fix(auth): stop losing preserved plaintext, and claim the hand-off file atomically
Round 19 found real data loss rather than process noise, in two places.

**An unrelated settings edit destroyed the secret the durability gate had
just preserved.** The GET migration withholds its strip for a session-scoped
store, so `mcp.json` stays the durable copy — but the POST/PUT paths always
wrote the stripped shape. The GET also returns the rehydrated secret to the
settings form, which resends the whole object when the user changes anything,
so editing a timeout wrote the stripped entry, moved the only durable copy
into RAM, and lost it at exit. Every operation reported success. The client
config had the identical shape: the read-path guard was there, the write path
had none, and saving a CIMD URL did the same thing to the IdP client secret.

Both write paths now agree with their read paths: while the store cannot
outlive the process, the file stays the durable copy.

**The hand-off's delete is no longer a check-to-delete race.** Re-reading
immediately before `rm` caught writes that had already landed and was blind to
the one arriving between the check and the delete — destroying a *later*,
already-successful write, which is worse than the optimistic-write residual
and, unlike it, fixable without a lock. The file is claimed by an atomic
rename before anything reads it, so the migration operates on a snapshot
nobody else can reach and a writer that recreates `secrets.json` is simply
untouched.

Claiming introduced a failure mode the existing tests caught immediately: a
migration that aborts must not leave the secrets stranded at the staging path.
The snapshot is restored on any incomplete outcome, and when the live path has
been recreated in the meantime it is left in place with its location logged —
an operator-visible orphan is recoverable; clobbering someone's write is not.

Also:

- **`setMany`**, the write-side twin of `getMany`. `set` costs a whole-file
  read-decrypt-encrypt-write-verify cycle per call, and the settings form
  resends a server's full `env` map on any edit, so a 10-variable stdio server
  spent ~30 scrypt derivations saving an unrelated change. One cycle now; the
  keychain keeps its parallel individual writes.
- **A doc that contradicted its function.** `readOnDiskEncryption` still said
  it "never decrypts, so it needs no passphrase" — round 12 made it
  authenticate precisely because structure is not access.

Full `npm run ci` green, staged tree verified before running it.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 21:34:34 -04:00
cliffhall
98d1fe752d fix(apps): gate the capability observer on the bridge's own initialize schema
The hand-rolled accept check was shallower than the bridge's, so frames it
rejects could still set `elicitation` here — `appInfo: {}` passed, though
`McpUiInitializeRequestSchema` requires an implementation name and version.
Any approximation drifts; the schema itself does not, and ext-apps exports it.

Capabilities are still read from the ORIGINAL frame rather than the parse
output, since that schema is precisely what strips `elicitation` — validating
with it and reading through it would defeat the module. The one check the
schema cannot make is that the frame is a request at all (a notification
carries the same method and params), so the id check stays.

Also stops treating a truthy non-object as an advertisement: the draft declares
`elicitation` as an object, so `elicitation: true` is a value it does not
define and the bridge's schema will reject it the moment it carries the key.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 21:29:55 -04:00
cliffhall
469e59a7c4 fix(auth): commit the utility round 17 wired in, and drop a prohibited cast
**Round 17 pushed a branch that does not compile, and I reported it green.**
`refreshingPersist.ts` and its test were new files; `git commit -s -a` stages
modified *tracked* files and not untracked ones, so `App.tsx` went to origin
importing a module that was never committed. `npm run ci` passed against my
working tree, where both files exist — so the claim was true of what I had and
false of what I pushed, which is the worst shape a green result can take.

That is the second staging mistake on this branch. The first was `git commit
-o <paths>`, which commits whole files rather than my hunks and swept in a
collaborator's in-flight tests. Both come from trusting a flag's shorthand
instead of reading what was actually staged, so from here the commit goes
through an explicit `git add -A` plus a look at `git status --short` before
the gate runs — the check is cheap and it is the only one that describes the
pushed state rather than the local one.

Also replaces the `as unknown as` on the test logger. The rules prohibit it,
and it was doing real damage beyond style: forcing a four-method object
through `pino.Logger` erased the fact that the stand-in was not one, so a
change in how the server logs — a child logger, bindings, a different level —
would have gone unnoticed. It is a real pino logger writing to a capturing
destination now, which needs no cast because the destination interface is
satisfied structurally.

Both remaining suppressed comments are the cross-process guarantee, tracked
in #2082.

Full `npm run ci` green, run against a fully staged tree this time.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 21:06:47 -04:00
cliffhall
b86db5c7a2 fix(apps): track the latest accepted app ui/initialize, not the first
The previous round froze the recorded capabilities at the first handshake, on
the belief that the bridge ignores re-initialization. It does not: ext-apps
1.7.5's `_oninitialize` warns about the double-mount and then assigns
`_appCapabilities` and `_appInfo` from the new frame — "the latest appInfo/
appCapabilities replace the previous values", in its own words. Freezing left
this gate reporting capabilities the bridge no longer held, in both directions:
a handshake advertising `elicitation` followed by one without it still read as
advertised.

Every frame that passes the accept gate now replaces the recorded value, and
`appCapabilities` joins `protocolVersion` and `appInfo` in that gate since the
bridge's own schema requires all three. A frame the bridge would reject still
records nothing AND leaves the previous value alone — it is a route to changing
the gate in neither direction.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 21:05:47 -04:00
cliffhall
78a65f7c20 fix(auth): address Copilot review round 17
Three findings. The first is the fourth consecutive one in the refresh path,
so it is answered by making that path testable rather than by patching it
again.

**The refresh-after-persist wiring was covered by nothing.** It lived as two
inline `await …; refresh()` pairs in `App.tsx`, which is deliberately outside
the coverage `include` — so the hook's tests and the modals' tests could both
pass while the callback joining them was broken, leaving the security footer
stale after exactly the write that changed it. Three of the last four rounds
found defects in this path; none of them could have been caught by a test,
because there was nowhere for one to live.

Extracted to `utils/refreshingPersist`, which is inside the gate and has its
own tests: refresh runs after the persist resolves (not before — refreshing
first re-reads the descriptor the pending write is about to invalidate),
arguments pass through unchanged (the server-settings caller is
`(id, settings)`, and a transparent wrapper is the difference between saving
the entry and dropping it), and a failed persist refreshes nothing while
propagating the error.

**A tmpfs-style false promise, in the path fallback.** With neither `HOME`
nor `USERPROFILE` set — an ordinary service environment —
`defaultSecretFilePath` returned `.mcp-inspector/secrets.json`, a *relative*
path, while `FileSecretStoreOptions.filePath` and the `SecretStorageInfo.path`
the footer offers to copy both promise an absolute one. Resolved like the two
override branches above it.

**A warning that was usually false.** The session-store notice fired on every
`/api/servers` read whenever the store was in-memory — including for the
default empty catalog, where "plaintext values are left on disk" is not true
of anything, repeated on every list refresh. It is now emitted lazily, at most
once per server instance, and only when the loop actually preserves a
non-empty secrets set. A log line that is usually untrue is one people learn
to skip, which costs it the occasion it matters.

The remaining suppressed comment is the hand-off's check-to-delete race,
tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:51:23 -04:00
cliffhall
cc0770babd fix(apps): fail closed on a malformed app ui/initialize
The capability observer recorded `params.appCapabilities` from ANY frame whose
method was `ui/initialize`, before the bridge validated it. A view could
therefore send a second, malformed initialize — one the bridge rejects, keeping
the capabilities it had already accepted — and flip `elicitation` on in this
gate, after which the host would forward an elicitation the bridge never
negotiated.

Now only a well-formed initialize REQUEST is recorded (a JSON-RPC id, plus the
handshake's required `protocolVersion` and `appInfo`), and only the first one:
the bridge keeps what it accepted and ignores re-initialization, so this gate
agrees rather than offering a second, laxer path to the same flag. A malformed
first frame records nothing.

This is a gate, not a second copy of the bridge's schema — the bridge stays the
authority on the rest of the frame.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:45:28 -04:00
cliffhall
52087f7739 fix(auth): address Copilot review round 16
Three findings. The first is the third consecutive round in which my previous
fix introduced the next defect, all in the same ~15 lines of `useInitialConfig`.

**The request token did not subsume the unmount guard, and I said it did.**
Bumping the generation on effect teardown invalidates loads already *in
flight*; a `refresh()` called after unmount claims the next token, is
therefore current, and commits into a dead hook. That is a live path — the
callers fire `refresh()` from an async settings persist that can outlive
`App`. Mounted state is tracked separately again and `stale()` checks both.

The test for this was also wrong, in the way that matters: it called
`refresh()` after unmount but never settled the fetch, so it could not observe
the commit it existed to prevent. It settles the promise now.

**A tmpfs mount was reported as durable.** `docker run --tmpfs
/home/node/.mcp-inspector` puts a real entry in mountinfo, so a mount-table
lookup answered "durable", selected the file store, and published
`durable: true` for a file that disappears when the container stops — the
exact false promise the in-memory fallback exists to avoid, reached by
trusting the mount table over the filesystem type. `tmpfs`/`ramfs` are
excluded now, read from the field after the ` - ` separator rather than by
position, since the optional fields before it are variable in number.

**A malformed envelope was diagnosed as a changed passphrase.** A truncated
payload, a wrong-length IV or tag, or an out-of-range KDF parameter all threw
`SecretFileKeyMismatchError`, whose 503 tells the user to restore a passphrase
that cannot repair a structurally broken file. The read path now runs the same
`encryptedEnvelopeProblem` check the descriptor path already ran, and reserves
the key-mismatch error for an envelope that is well-formed and fails
authentication — which is the case where that advice is right. Two existing
tests asserted the old behavior and encoded the bug; both updated, and a new
one pins that a well-formed envelope with the wrong key still gets the
passphrase advice.

The remaining suppressed comment is the hand-off's check-to-delete race,
tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:31:03 -04:00
cliffhall
44010c313b test(apps): cover the modern MRTR leg of app-rendered elicitations
Both review findings from round two.

The routing change sits in the funnel BOTH entry points share, but only the
legacy inbound handler was driven by a test — the modern leg, where an
elicitation arrives as an `input_required` result the MRTR driver unpacks and
retries, was covered by construction alone. Adds
`mrtr_app_choose_option` (a modern MRTR tool whose *embedded* elicitation
carries `_meta.ui.resourceUri`) and drives it live over a real transport: the
app answers, the retry carries that answer through `inputResponses`, and the
server echoes it back as the tool's result. A second case cancels the tool
call while the app is up and asserts the request-scoped signal aborts; a third
runs the same tool against a modern server that never advertised the
capability and asserts the native queue takes it.

The cancellation case turned up a real gap: `tryAppElicitation` mounted an app
for an already-aborted signal (the caller cancelled during an earlier MRTR
round), leaving a modal nobody was waiting on and a promise that could never
settle. It now throws the abort straight through.

Also fixes the smoke count in the root README, which still said "two"
headless-Chromium smokes and then introduced a third.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:17:37 -04:00
cliffhall
0ab542cbee fix(auth): order /api/config loads so an older response cannot win
Round 15, one finding — in the refresh path added one commit ago.

`refresh()` fires after each secret-bearing save, and nothing ordered the
responses. Two debounced settings saves landing close together issue two
refreshes; if the earlier request resolves last, it commits the descriptor
from *before* the write and the footer reverts to "Plaintext file" for a file
that is now encrypted. Reverting a security statement to a stale value is the
worst direction for this particular field to be wrong in, and it needed no
adverse network conditions — just two saves in quick succession.

Loads now claim a monotonic token and may commit only while they still hold
the current one. That subsumes the liveness ref the previous commit added:
bumping the token on effect teardown covers unmount *and* a `baseUrl` /
`authToken` change, and bumping it on every load covers the overlap.

The test drives it by holding the `fetch` promises and resolving them out of
order — newer first, then older — which fails against the previous
implementation and passes here.

The remaining suppressed comment is the hand-off's check-to-delete race,
which is the cross-process guarantee tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:14:23 -04:00
cliffhall
0c52172379 fix(auth): address Copilot review round 14
One inline and three of the four suppressed. Two of the four are cases where
an earlier fix of mine was applied to one branch and not its twin.

**The footer went stale after the write that changed it.** `useInitialConfig`
fetched once, and `secretStorage` is the one field it carries that describes
state *this app mutates*: the first save under a newly-set passphrase
re-encrypts a pre-existing plaintext file. The backend re-derives the
descriptor per request — deliberately, and there is a test for that — but the
client had no way to ask again, so the footer said "Plaintext file" for the
rest of the session about a file that no longer was. Exactly the misreport
this band exists to prevent, arrived at from the client side.

The hook now exposes `refresh()`, and both persist paths that can carry a
secret call it: server settings (OAuth client secret, stdio `env:` values)
and client settings (the enterprise IdP client secret). The refresh tracks
its own liveness through a ref, since unlike the mount fetch it has no effect
teardown to cancel against.

**The plaintext branch skipped the payload validation the encrypted branch
does.** Round 12 tightened the encrypted envelope check and left this one
behind, so `{ "encryption": "none", "secrets": [] }` — trivial to hand-write —
described a healthy plaintext store whose next save `readMap` refuses. Both
branches validate now.

**`secretStoreGetStrict`'s doc contradicted the code it was written for.** It
said falling back to `get` is right "for the in-memory and file stores —
neither can fail in a way that masquerades as absence". `FileSecretStore.get`
does exactly that masquerade, which is why round 9 gave that store its own
`getStrict`. The fallback stays (the seam is optional and doubles must
compile) but the comment no longer claims it is equivalent for a store that
can fail.

**The spec documented the performance bug rather than the fix.** It still said
`getMany` decrypts once per server, which was the shape round 13 replaced —
and describing the earlier bug as the design is worse than saying nothing.

The remaining suppressed comment is the hand-off's TOCTOU delete: the
cross-process guarantee, decided against for this PR and tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:56:41 -04:00
cliffhall
7c77c2b15f fix(apps): address review of app-rendered elicitations
Eight findings from the Copilot review, all accepted.

Ownership across connections was the substantive one, in three parts. A
mid-session transport close reaches teardown only through
`clearAndAnnouncePendingPeerRequests`, whose emptiness check looked at the
native queues alone — so a lone app elicitation survived a dropped connection
with its modal still open; the check now counts the active set too. The web
bridge factory resolves its client at call time, so an entry queued by a
replaced `InspectorClient` could rebuild against the *next* one and answer
through a different server; the host now drops every entry synchronously on
the swap (`AppElicitationController.failAll`) rather than racing core's
awaited teardown. And the request id was only monotonic within one client, so
each replacement client's first request was `app-elicitation-1` — settling
that id could resolve the wrong server's request. It now carries a
per-instance prefix.

Result validation now parses the whole value with `ElicitResultSchema` before
the requested-schema check, instead of inspecting `action` by hand: a
`{ action: "decline", content: { x: {} } }` used to pass, though the standard
result permits only primitives and string arrays in `content`.

The dialog's accessible name now includes the request id and the prompt — two
concurrent elicitations can be for the same app URI, and the previous name
made those indistinguishable, which is what the comment beside it claimed to
prevent.

`observeAppCapabilities` takes a minimal structural `onmessage` contract
(generic over the message type, since a handler typed for a narrower message
is not assignable to one typed for `unknown`), removing the double cast at the
call site.

`smoke:web:elicit` now fails on an uncaught page error after a successful
drive, and captures the async half of that class off the console channel, as
its two sibling smokes do — it could otherwise print OK over a broken bundle.

Adds the tests each fix needs, including the two the review asked for
directly: that `advertiseElicitation` reaches the `AppBridge` constructor, and
that it is absent by default.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:43:44 -04:00
cliffhall
26d2f58784 fix(auth): address Copilot review round 13
No new inline comments this round, but two of the four suppressed ones were
real defects rather than restatements — both in code I added, both silently
wrong in the safe-looking direction.

**A symlinked secrets directory read as unmounted.** `isOnMountPoint` walks
to the nearest existing ancestor *lexically* and then string-prefixes it
against mountinfo, so `~/.mcp-inspector -> /data/inspector` with `/data`
mounted matched no mount point. The container then selected the session-only
store on a box that had explicitly arranged for persistence, and nothing said
so — the mount was there, the volume was there, the secrets evaporated on
exit. Resolved through symlinks now, tolerantly: a path that cannot be
resolved falls back to the lexical comparison, which is what this did before
and is correct whenever no symlink is involved. The `statSync` fallback branch
already followed links, so this also makes the two paths agree.

**The bulk read did not fix the case it was written for.** `getMany` batched
*one server's* fields, but both rehydration callers iterate servers — so a
catalog of 20 servers holding one secret each still paid 20 serialized scrypt
derivations. The same ~450ms stall the seam was introduced to remove, reached
one server at a time instead of one field at a time. Measuring the wrong unit
and then optimizing it is worse than not optimizing, because the number in the
commit message says it is fixed.

The seam is cross-server now — `getMany(requests: SecretBulkRequest[])` — and
both callers pass the whole catalog in one call. `FileSecretStore` decrypts
once for the entire rehydration; the keychain fallback still issues
independent parallel `get`s, which is what it wants. A test asserts one
derivation for four servers, next to the existing one asserting one for three
fields, so the property is pinned at both axes.

The other two suppressed comments are the cross-process guarantee — `mutate`'s
residual and the hand-off's TOCTOU delete — which remain decided against for
this PR and tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:39:45 -04:00
cliffhall
bc4b774d80 fix(auth): address Copilot review round 12
Three inline findings and one of the two suppressed. The other suppressed
one — that the hand-off's delete is still a TOCTOU — is the cross-process
guarantee, decided against for this PR and tracked as #2082.

**My convergence tests did not converge anything.** The reviewer caught that
the "two stores racing" tests cannot race: `serialize` keys its queue on the
resolved path *process-wide*, which I made it do in the previous commit
precisely so two instances would take turns. So the tests passed by
serialization while their names and comments claimed they were exercising
the optimistic retry, and the retry's successful path had no coverage at all.
A fix I was pleased with silently disabled the tests written to prove it
worked.

Split in two now, both honest about what they drive:

- `in-process mutations are serialized per path` keeps the multi-instance
  cases, which pin a real property — it is what makes the in-process case
  correct *without* relying on the retry.
- `cross-process convergence` injects one external write between the store's
  write and its verifying read, via raw `fs` so it bypasses the queue the way
  another process would. The surviving entry can only be explained by the
  re-apply, so these pin the mechanism rather than the outcome. Covers a
  set, a delete, and the encrypted path where every attempt re-derives.

**A well-formed envelope is not an openable one.** `readOnDiskEncryption`
classified by structure alone, so a wrong or unset `MCP_INSPECTOR_SECRET_KEY`
produced the quiet, healthy "Encrypted file" footer for a file whose every
`get` returns `null` and every `set` is refused — the exact state the
unreadable footer promises to warn about. It now authenticates: decrypt and
discard, costing one scrypt derivation (~23ms) per descriptor build, once per
`/api/config`. A test asserted the old behavior and has been rewritten; the
comment on it explains why "describable" has to mean *accurately* describable.

**The copy button's `aria-label` was eating the warning.** `aria-label`
replaces descendant text, so a screen-reader user heard "Copy secrets file
path …" and never heard "Plaintext file" — the one sentence that should
change their mind about typing a secret. The status now leads the accessible
name and the path follows, which also keeps the name a superset of the
visible text (WCAG 2.5.3).

**The spec still described the pre-#1950 invariant** in its migration
bullets: every plaintext secret lifted into the keychain and removed from
`mcp.json`, with only `KeychainUnavailableError` named. Both bullets now
describe the selected store, the durability gate that withholds the disk
delete for a session-scoped store, the strict read behind the store-wins
lookup, and `SecretStoreUnavailableError` as the family the handler catches.

Full `npm run ci` green, including the per-file ≥90 gate — the new validator
and retry branches needed six more cases to clear it.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:09:16 -04:00
cliffhall
08fc20a22d feat(apps): negotiated app-rendered form elicitations
Let a server hand a form `elicitation/create` to an MCP App and return the
app's ordinary `ElicitResult`, with the native elicitation form as the
fallback on every failure.

No second extension, no custom method, and no custom result shape: the only
new wire surface is a nested `elicitation` flag on the existing
`io.modelcontextprotocol/ui` extension on each side, plus
`_meta.ui.resourceUri` on the request. App rendering is selected only when
all four gates hold — client `elicitation.form`, client MCP Apps MIME type,
the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://`
URI on the request.

Only the web client advertises the nested client-side setting, and only
because it has a sandbox renderer: supplying
`InspectorClientOptions.appElicitation` is what opts a client in, so CLI and
TUI keep advertising the MIME type without ever claiming they can resolve an
elicitation through an app.

Routing lives in the one funnel both entry points already use
(`enqueuePendingElicitation`), so the inbound handler and the MRTR driver
cannot diverge. Ownership is request-scoped — a per-request id keys the
renderer, iframe and bridge — so two concurrent elicitations can never
resolve through each other's bridges. An explicit `decline`/`cancel` is a
completed elicitation and goes back to the server; everything else (absent or
malformed metadata, resource/sandbox/bridge failure, an app with no
elicitation capability, a timeout, an invalid result or one that fails the
requested schema) falls back.

The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet
consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5)
predates that PR. `core/mcp/appElicitation.ts` and
`AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for
deletion once a release containing it ships. One consequence needed its own
seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that
strips `elicitation`, so an app that correctly advertises it would look like
one that did not, silently turning every negotiated elicitation into a
fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and
prefers the bridge's own value once it carries the key.

`AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so
the same renderer and bridge factory serve an App tool and an elicitation
without either faking the other's shape.

Adds the public fixture (`app_choose_option` + a self-contained
`ui://demo/choose-option.html` app covering accept/decline/cancel), its two
showcase configs, and `smoke:web:elicit`, which drives the negotiated path
end to end in headless Chromium and then the same tool against a server that
never advertised the capability, asserting the native form takes it.

Closes #1854

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:05:33 -04:00
cliffhall
219765efff fix(auth): address Copilot review round 10, and document the concurrency design
The headline is a correction to my own claim rather than a new defect.

**The optimistic verify is weaker than the comment said it was.** It
described the residual as a crash between the write and the verifying read.
The reviewer pointed out that no crash is needed: order two writers as
write-A, verify-A, write-B, verify-B and both report success while A's entry
is gone — A's verify simply ran before there was anything to see, and B did
nothing wrong. A crash is one instance of that shape, not the whole of it.
The trace in the old comment quietly assumed A verifies *after* B writes,
which nothing guarantees.

Corrected in the doc comment, the README and the spec, and the honest
summary is now stated rather than implied: this converges in every
interleaving where the clobber lands before the verify, it is not mutual
exclusion, and a deployment that needs the guarantee wants an OS-backed lock
from a dedicated library — not another hand-rolled election. It remains
better than what it replaced, which lost updates across a wider set of
interleavings and could not be closed at all with what Node exposes.

The other findings:

- **An envelope naming the cipher still is not an openable one.** Round 9
  added a `kdf`/`data` presence check; three dot-separated strings satisfied
  it while an empty tag, a 4-byte IV or `N: 3` still made every decrypt
  throw — so the quiet "Encrypted file" footer went to a file whose next
  save was guaranteed to fail. Now validates part sizes, a non-empty
  ciphertext, and the scrypt cost parameters (node rejects a non-power-of-two
  `N` synchronously, out of the derivation rather than the decrypt). A
  positive control asserts a file we actually wrote still reads as encrypted.

- **`encryptionUnknown` + `looseMode` claimed an exposure it had not
  established.** With both set, `plaintext` is absent, so a `!== false` test
  landed on "anyone who can read it can read the secrets in it" — about a
  file that may well hold ciphertext we simply could not classify. Three-way
  now, in `secretStorageCaveat` and mirrored in the footer's tooltip, with a
  test on each.

- **The README described a lock that no longer exists**, which is the worst
  kind of stale doc: it stated a durability guarantee the code had stopped
  providing. Rewritten to describe the actual design, including the residual.

- **The private-method access in the concurrency test** now carries the
  justification the repo's TypeScript rules require: the asserted shape is
  `writeMap`'s real signature, so a rename breaks the line instead of
  silently detaching the stub, and a production injection seam would let the
  real write path drift from the tested one.

Also updates `specification/v2_servers_file.md`, which still described #1356's
world: one keychain-only store, and unavailability as a hard error. It now
covers store selection and the probe, the file format and lazy encryption
upgrade, the concurrency design and its residual, the `getStrict`/`getMany`
seams and the two traps `getStrict` hit, the durability gate on migration,
the keychain hand-off and its conditional delete, and how the active store is
surfaced.

Full `npm run ci` green. (One unrelated `AppRenderer` theme-flip test failed
once under full-suite parallelism and passes in isolation with and without
these changes — a flake, not a regression.)

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 18:33:59 -04:00
cliffhall
339aea3729 fix(auth): replace the secrets-file lock with optimistic verify-and-retry, and address review round 9
Two changes, interleaved in the same files because both are about the
same read-modify-write path.

## The lock is gone (maintainer decision on the round-5/7/8 finding)

Three consecutive review rounds found a real race in the `mkdir` lock, and
the last one is not patchable with what Node exposes: claiming a stale lock
needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`).
Without it, `canSteal` inspects one lock but `rename` acts on whatever is at
that path *later*, so a waiter that lost the first race can move the
winner's fresh lock aside and enter alongside it.

Rather than patch it a fourth time, writers now collide and the loser
notices. `mutate` reads `M0`, applies the mutation to get `M1`, writes it,
then reads back `M2`; if they differ, someone wrote in between, so it
re-applies onto what they left and retries, bounded.

The comparison is over the **whole map**, and that is the load-bearing
detail. Checking only the entry we touched passes in exactly the case that
loses data — ours is present, and it is the *other* writer's that is gone.
With A and B from the same `M0`: A writes `MA`, B writes `MB` and clobbers
A; B verifies and correctly sees `MB`; A verifies, sees `M2 !== MA`,
re-applies onto `MB`, writes again. Both survive, and the writer that was
clobbered is the one that repairs it.

Two things this deliberately does not claim:

- It is **not mutual exclusion**. A process that crashes between its write
  and its verifying read cannot notice it was clobbered, and nothing else
  will. That window is strictly narrower than the lock's — which lost
  updates with every participant alive — and needs no primitive Node lacks.
  Stated in the code rather than left to be rediscovered in review.
- Non-convergence **fails loudly**. After five lost rounds `set` throws
  rather than returning, because returning would reintroduce the silent
  loss with extra steps. `delete` stays silent, per the interface contract.

`serialize` is now keyed on the resolved path **process-wide** rather than
per instance. Two `FileSecretStore`s on one file are ordinary — the resolved
store holds one and `absorbFileSecretsIntoKeyring` builds another — and a
per-instance queue does not order them against each other at all. The new
convergence test caught this on its first run: two instances racing `set`
lost an entry. In-process is now correct by construction; the verify covers
what it cannot see, a second Inspector process.

The hand-off's read → copy → delete was the lock's other user. Its delete is
now conditional: the file is re-read immediately before removal and compared
against what was copied, and it declines to delete when they differ. Leaving
the file costs nothing (keychain-wins makes the next run idempotent) while
deleting on a stale read costs a secret.

## Round 9 (two inline, two suppressed)

- **`KeyringSecretStore.getStrict` let raw binding errors escape.** Both
  plaintext migrations keep their source file only when they catch
  `SecretStoreUnavailableError`, so a transient keychain failure 500'd
  `GET /api/servers` instead of abandoning the migration. Wrapped, as `set`
  already does.
- **`FileSecretStore` had no strict read at all**, so `secretStoreGetStrict`
  fell back to the tolerant `get` and mapped every read failure to `null` —
  leaving the exact inversion the seam was introduced to prevent open on the
  store it was introduced for.
- **`readOnDiskEncryption` reported "encrypted" for an envelope it cannot
  open.** Naming the cipher is not the same as being openable: a file with no
  `kdf`/`data`, or a `data` that is not `iv.tag.ciphertext`, produced the
  quiet reassuring footer for a file whose next save was guaranteed to fail.
- **`/api/config` kept a stale descriptor when the resolver returned
  `undefined`**, because the conditional spread left the startup value
  standing. Assigned unconditionally now; `c.json` omits the key.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 18:05:26 -04:00
cliffhall
37faf1be88 fix(auth): address Copilot review round 8 (four inline findings)
The lock finding from this round is deliberately not addressed here — see
below.

- **My round-6 strict-read fix was inert in production.** `DeferredSecretStore`
  is what `defaultSecretStore()` returns, and it did not forward `getStrict`
  — so `secretStoreGetStrict` fell through to the tolerant `get` for every
  real caller, and the strictness existed only in tests that injected a
  concrete store. Forwarded now, along with `getMany`.

- **The same tolerant-read bug remained in both pre-existing migrations.**
  I added `getStrict` for the new hand-off in round 6 and never applied it to
  the mcp.json and client.json migrations, which have the identical
  shape: decide from `get`, then write on `null`. A transient keychain read
  failure therefore let an older plaintext value overwrite a newer keychain
  one — and the disk copy was then stripped, so the newer value was gone. Both
  use the strict read now; the throw is a `SecretStoreUnavailableError`, which
  both call sites already handle as "abandon the migration, keep the file".

- **The permission check made a false statement on Windows.** `stat` returns
  synthetic bits there, and interpreting them yields a confident falsehood
  either way — "0666 — not owner-only" about a file the ACL may restrict, or
  "owner-only" about one it does not. The ACL is the real answer and this does
  not inspect it, so Windows now reports `unknown`.

- **Rehydration cost one full decryption per field.** `get` reads and decrypts
  the entire file, and both rehydration paths ask field by field, serialized
  behind the store's own queue. Measured on this machine, scrypt at N=16384 is
  ~23ms per derivation, so a 20-secret encrypted catalog spent **~450ms of
  pure key derivation on every `GET /api/servers`** — a visible stall, not a
  micro-optimization. Added an optional `getMany` seam: `FileSecretStore`
  decrypts once per server, everything else falls back to the parallel
  per-field `get` that already suited it. A test counts derivations directly:
  1 for a three-field `getMany`, 3 for the three `get`s it replaced.

Also broadens a log message that still said "Keychain unavailable" for a
store that may not be a keychain.

Full `npm run ci` green — 5,762 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 17:41:56 -04:00
cliffhall
5f4a6ea09b test(auth): pin the loose-mode test to the mechanism that survived
No behavior change.

Round 7 removed the loose-mode `console.warn` from selection — it was
encryption-blind and duplicated the caveat printed moments later. This test
kept asserting `warn` was called with "0644" and kept passing, because the
caveat also contains the mode. So it was green while describing a code path
that no longer exists: named "warns once at selection", commented as if the
selection warning were the thing under test, and pinned to a substring loose
enough that either line would satisfy it.

Asserts the caveat's full sentence now, so it is fastened to the surviving
mechanism rather than to whichever line happens to mention the mode.

Also records what it took to reach this state at all, since it is not
obvious and I had it wrong: as the file's owner you *cannot* stage a
loose-mode descriptor, because `tightenSecretFilePermissions` repairs a 0644
file to 0600 during selection and the report is then correctly "owner-only".
It is only real for a file owned by another user or on a read-only mount,
which is why the chmod is mocked — standing in for a privilege a test cannot
drop.

Found by @mcp-inspector-v2-f1, who tried to screenshot the state for the PR
body and got "Owner-only permissions." instead.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 17:12:29 -04:00
cliffhall
6efc65d2af fix(auth): address Copilot review round 7 (five of six findings)
Five taken; one rejected with evidence, below.

- **The keychain hand-off ran outside the file lock.** Read → copy → delete
  has to be one transaction: another Inspector completing a `set` between
  the read and the delete had its brand-new secret removed without ever
  being copied — a write that reported success and then vanished, which is
  the loss the lock exists to prevent, reached through the migration meant
  to preserve things. The whole sequence now runs under the same
  cross-process lock every mutation takes.

- **An unmigratable entry was skipped and the file deleted anyway.** A key
  that is not `serverId:field` cannot be addressed through the store API,
  so it cannot be copied — which makes the hand-off incomplete by
  definition, and the complete-success rule then forbids removing the
  source. It was discarding that value permanently in order to tidy up a
  migration that had failed. The copyable entries still go across; the file
  stays, and says why.

- **`readOnDiskEncryption` accepted any format version.** A version-2 file
  was reported as a usable plaintext or encrypted store even though
  `readMap` refuses it and every save fails. Checked before the mode now,
  matching `readMap`.

- **The loose-mode warning at selection was encryption-blind**, claiming a
  reader could read the secrets of a file that is ciphertext, and it
  duplicated the caveat printed moments later by `warnAboutSecretStorage`.
  Selection now only repairs; the descriptor path does the reporting,
  because it is the one that knows whether the file is encrypted.

- **The footer's tooltip offered to copy the path without showing it.** The
  band stopped printing it and the accessible name carries it for
  screen-reader users, which left a sighted user unable to see where a
  custom destination points without pasting it somewhere else.

**Not taken: "`writeStoreFile` is not atomic."** It is. The finding reads
`store-io.ts` line 84 as a direct write, but `writeFile` there is imported
from the `atomically` package, not `node:fs`. Verified rather than argued:
writing over an existing file changes its inode (so a rename happened) and
leaves mode `0600`. I had started implementing the suggested temp+rename
before checking the import, which would have replaced a tested library with
a hand-rolled duplicate.

That check corrected something of mine, though: `tightenSecretFilePermissions`
justified itself with "a rename over a 0644 file keeps the loose mode
forever", which is false — the temp carries `mode: 0o600` and the rename
replaces the inode, so every *write* re-establishes it. The function is
still needed for the case it actually addresses: a file that exists and is
never written. Comment corrected to say so.

Full `npm run ci` green — 5,753 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:57:55 -04:00
cliffhall
fdeffa1f00 fix(auth): address Copilot review round 6 on the secret-store PR
Four findings, all suppressed, all real. Three are the same mistake in
different places: a state that was never established being reported as a
confident answer.

- **`readOnDiskEncryption` conflated "no file" with "unreadable file".**
  Both answered `null`, and the descriptor falls back to the configured
  write policy on `null` — so a passphrase-configured install reported
  "File (encrypted)" about a file whose envelope had never parsed, and
  which `set` was in fact about to refuse rather than overwrite. It now
  returns a four-state result, and only `absent` falls back to the policy
  (honestly: the first write really will create it that way). `unreadable`
  produces `encryptionUnknown` on the descriptor, `plaintext` is omitted
  entirely so nothing can read a default out of it, and the label, caveat,
  tone and footer all carry "unreadable" rather than guessing a mode.

- **The loose-mode warning overstated exposure for an encrypted file.**
  "Anyone who can read it can read the secrets in it" is false when the
  file is ciphertext — a reader gets the bytes and still needs the
  passphrase. Both the caveat and the footer tooltip now branch on
  encryption: the passphrase becomes the only protection, which is the
  real consequence. Overstating it is not harmless, because a warning that
  cries wolf is discounted on the occasion it is literally true.

- **The keychain hand-off could invert its own keychain-wins rule.**
  `SecretStore.get` answers `null` both for a missing entry and for a
  keychain it could not read, and the migration *writes* on `null` — so a
  transient read failure would overwrite a newer keychain value with the
  older file copy. Added an optional `getStrict` (throws instead of
  swallowing) implemented by `KeyringSecretStore`, with
  `secretStoreGetStrict` falling back to `get` for stores whose reads
  cannot fail. A throw now aborts the hand-off with the file left in
  place, so the next run retries.

Writing the tests turned up one more, in code the review had passed:
`parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is
a *wildcard* asking about every process this user can signal — it answers
"alive" almost always, which would have made a lock with a corrupt stamp
permanently unstealable. Only a positive pid is usable now; anything else
is treated as no pid at all and the mtime decides.

Tests: the four envelope states kept distinct; the descriptor refusing to
guess, with the genuine no-file case as the control; strict-read abort
without a write and with the file preserved, plus a store whose `get` and
`getStrict` disagree so the test proves which one the migration consults;
encryption-aware wording asserted in both directions in the caveat and the
band; three unusable owner stamps and a wildcard pid; and the descriptor
shapes that must now be rejected — neither half of the encryption question
answered, or both. Full `npm run ci` green — 5,750 tests under the
coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:40:29 -04:00
cliffhall
1aa1f5a0ca docs(web): attach the footer doc blocks to the functions they describe
No behavior change.

Adding `filePermissionsSentence` in decc673a inserted it directly under
`footerMessage`'s doc block, which left two block comments stacked and each
attached to the wrong function: `footerMessage` was undocumented, and its
"why not `secretStorageLabel`" rationale read as though it were about the
permissions helper, which it is not.

Moves that block back above `footerMessage`, and drops the inline comment
inside it that now duplicated the new helper's doc — and had gone stale
besides, since it still described two permission states rather than three.

Caught by @mcp-inspector-v2-f1 on a read of the pushed commit.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:24:09 -04:00