Commit graph

2,747 commits

Author SHA1 Message Date
cliffhall
edddc6c9cc fix(auth): hold the snapshot lock across the main lock's release (#2082)
Copilot review round 8 — two findings, both upheld. The first is a gap in
round 7's own fix.

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

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

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

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

New test verified by mutation.

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

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

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

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

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

Both new tests verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:54:47 -04:00
Cliff Hall
6829b45f60
Merge pull request #2090 from modelcontextprotocol/v2/chore/2085-max-warnings-zero
chore(lint): fail lint on warnings (--max-warnings 0)
2026-08-23 11:39:27 -04:00
cliffhall
09e1564672 fix(auth): refuse on a stuck lock, capture identity at the mkdir seam (#2082)
Copilot review round 6 — five findings (two inline, three suppressed), all
valid.

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

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

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

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

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

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

Both new tests verified by mutation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 10:51:40 -04:00
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
833a6ba76b fix(auth): keep the keychain hand-off non-throwing, and stop overstating the wait (#2082)
Copilot review round 3.

- **`absorbFileSecretsIntoKeyring` must never throw, and round 2 broke that.**
  Making `withSecretFileLock` reject on a held lock was right for `set` — a
  user is waiting on that value — and wrong here: this is awaited directly by
  both `resolveSecretStore` branches, so a stuck writer elsewhere on the box
  would have failed store resolution and with it the whole session. The lock
  failure is now caught, warned, and the file left for the next run. Covered
  by a test that holds the lock from another handle and asserts the hand-off
  resolves, the file survives, and the lock-specific warning is emitted (it
  asserts that message rather than the shared "left in place" tail, which the
  pre-existing claim-failure path also prints and would have let it pass
  without the lock ever being reached).
- **The refusal message overstated how long it waited.** `retries *
  maxTimeout` reads 20s for a schedule that sums to 15.26s — the early
  attempts are the exponential ramp, not the cap. Replaced with
  `RETRY_BUDGET_MS`, computed from the schedule rather than written down, so
  it cannot drift from the thing it describes. `retry` applies no jitter by
  default, so it is exact rather than an estimate.
- **The PR description still described the pre-round-2 design** — the
  single-winner claim and the ~3s backoff. Rewritten to match the code.

Coverage: the round-3 changes cost `secret-store-selection.ts` two branches
and it fell to 89.62%. Recovered honestly rather than by annotation — the
`ENOTDIR` arm of `anythingToMigrate` now has a real test (a path whose parent
is a file, the other shape of "nothing there"), and the new catch types its
parameter `Error` instead of re-narrowing, since `withSecretFileLock` rejects
only with the `SecretStoreUnavailableError` it constructs one call away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 10:32:43 -04:00
cliffhall
dcd70e2f11 fix(auth): stop degrading on ELOCKED, and correct the claim about the lock (#2082)
Copilot review round 2, both comments upheld.

**ELOCKED must not degrade.** Exhausting the retries on a held lock means the
lock is *working* and something else demonstrably holds it — so running the
mutation anyway entered the exact interleaving the lock exists to prevent,
and entered it knowing a concurrent writer was there. `set` now fails
instead. Two supporting changes:

- The retry budget went from ~3.3s to ~15s so it outlasts the 10s stale
  window. Refusing is only defensible because a *crashed* holder resolves by
  takeover first; with the shorter budget, one Inspector killed mid-save
  would have failed every later save on the box until someone deleted the
  lock by hand. Pinned by a new test that stages a dead holder's lock
  directly (mkdir + backdated mtime) and asserts the save succeeds.
- Acquire is now probe-then-wait. `proper-lockfile` drives its whole acquire
  through `retry`, which re-attempts on *any* error — so with a 20-retry
  budget a read-only `$HOME` would have spent the full 15s re-issuing an
  identical failing `mkdir` on every save before degrading. One retry-less
  probe separates "held" (worth waiting out) from "unavailable" (not).

**The comment overclaimed what proper-lockfile provides, and the reviewer was
right about the mechanism.** Verified against lib/lockfile.js@4.1.2: on
EEXIST it stats, and if stale it rmdirs and re-mkdirs *without* checking the
directory it removed is the one it found stale — so a slow waiter can still
delete a fast waiter's fresh lock and both proceed. That is the same race the
hand-rolled version could not close, and it is not closable without
renameat2(RENAME_EXCHANGE).

What the library actually adds is that the loser is *detected*: its refresh
tick compares the lock's mtime against the value recorded at acquire, so a
compromised holder is told rather than proceeding silently. And what is
genuinely exclusive is the case that matters — mkdir is atomic and a live
holder refreshes its mtime, so its lock never goes stale and two running
Inspectors are serialized. The residual window opens only after a holder dies
without releasing, and the optimistic verify still covers it.

file-lock.ts, README.md, specification/v2_servers_file.md and AGENTS.md all
carried the overclaim; all four now state the narrower, true version.

Also fixes a test that was quietly asserting the opposite of its name: its
holder used stale: 60_000 while the waiter used 10_000, and staleness is
judged by the *waiter's* threshold — so the holder was declared stale after
10s and the save succeeded by takeover.

Not done: a two-process stale-takeover race test. It would be testing the
library rather than this code, and is inherently nondeterministic; the honest
response to that finding was to stop claiming the race is closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 10:08:12 -04:00
cliffhall
16613b115d fix(auth): address Copilot review round 1 on the secrets-file lock (#2082)
- Create the storage directory before locking. `writeStoreFile` creates it,
  but from inside the locked section — so on a fresh install, where
  `~/.mcp-inspector` does not exist, proper-lockfile failed ENOENT and every
  *first* save degraded to an unlocked write with a warning. That is the save
  most likely to be racing another (two Inspectors started together both
  reach it), so the lock was absent from precisely the interleaving it exists
  to close.
- `anythingToMigrate` no longer reads every `readdir` failure as "nothing to
  migrate". Only ENOENT/ENOTDIR proves the fresh-install case; a directory
  that denies listing can still permit access to the known `secrets.json`,
  and returning false there selected the keychain and left those secrets
  invisible with nothing said. Other errors fall through to the under-lock
  checks, which is what happened before the fast path existed.
- Pin `proper-lockfile` to the repo-root install in `vitest.shared.mts`,
  beside express and yaml: it is reached only through root-owned `core/`.
  Resolution already finds the root copy since no client declares it; the pin
  is what stops that depending on it never arriving as some client's
  transitive dependency, which would give a test two copies of a module whose
  whole job is one registry of held locks.
- Make the cross-process test prove `mutate` takes the lock. The previous one
  asserted only that both secrets survived, which the optimistic verify
  delivers with the lock removed entirely. It now asserts the parent's `set`
  has *not* settled while a child holds the lock — an observation only a real
  lock produces. Verified by removing `withSecretFileLock` from `mutate`:
  three tests fail, where none did before.

The degrade test's "lock cannot be created" case is now a path whose parent
is a *file* rather than a missing directory, since a missing directory is no
longer that case. It also fails identically for root, so it cannot pass
locally and flake in a container.

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 09:49:01 -04:00
cliffhall
04dbc36544 feat(auth): give the secrets file a real cross-process lock (#2082)
#1950 shipped FileSecretStore without cross-process mutual exclusion, by
decision: an earlier revision hand-rolled a `mkdir` election with an owner
stamp, a heartbeat and a stale-takeover, and three consecutive review rounds
found a real race in it. The last one is not closable with what Node exposes
— claiming a stale lock atomically needs compare-and-swap on a directory
entry (`renameat2(RENAME_EXCHANGE)`) — so it was replaced with optimistic
verify-and-retry and the residual documented.

#2082 settles that as "borrow, don't hand-roll". `proper-lockfile` is what
npm itself locks with, and stale-takeover is precisely the problem it has
already solved: it re-stats the lock directory after claiming it and gives
the lock up when the mtime is not the one it wrote, so the loser of a
takeover race releases instead of proceeding.

- core/auth/node/file-lock.ts: `withSecretFileLock`. `realpath: false` so a
  file can be locked into existence (the very first `set` has no
  `secrets.json`, and the library's default resolves through `fs.realpath`);
  a warning in place of the library's `onCompromised`, which throws from a
  timer and would take the session down; and a degrade-with-one-warning path
  rather than a throw when no lock can be taken — this store exists for boxes
  missing the usual mechanism (#1848, #1905) and must not gain a new way to
  be unavailable.
- FileSecretStore.mutate holds it across the whole read-modify-write. The
  optimistic verify stays underneath and is not redundant: a lock is advisory
  between the processes that take it, so the verify covers a writer outside
  this codebase and covers the degrade path. The in-process queue stays too,
  and gains a second job — proper-lockfile is not reentrant, so serializing
  per path keeps ELOCKED meaning "another process".
- absorbFileSecretsIntoKeyring takes the same lock around orphan adoption and
  the atomic claim, behind a lock-free `readdir` fast path so the common
  startup (keychain available, no file ever written) neither creates a lock
  directory nor warns about one it could not create.

proper-lockfile is a root `dependency` per the placement rule, and is named
in all three bundler `external` lists: tsup externalizes what the *client's*
manifest declares, so a root-only CJS package was being inlined into the ESM
bundles, leaving esbuild's `Dynamic require of "path" is not supported` shim
that killed `--cli` at import time. That rule was undocumented; it is now in
AGENTS.md beside the placement rule that creates it, and mirrored into
.github/copilot-instructions.md.

Tests: 7 new in file-lock.test.ts driving a real second process (the existing
suite structurally cannot — `serialize` orders in-process callers before the
lock sees them), plus two for the migration fast path.

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 09:32:41 -04:00
Cliff Hall
7701c68028
Merge pull request #2076 from modelcontextprotocol/v2/feat/1950-file-secret-store
feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
2026-08-23 02:01:27 -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
Cliff Hall
380268e54c
Merge pull request #2083 from modelcontextprotocol/v2/feat/1854-app-rendered-elicitations
feat(apps): negotiated app-rendered form elicitations
2026-08-23 01:29:13 -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
9b775b0409 docs: document smoke:web:elicit in AGENTS.md
The command reference still ended `npm run smoke` at `smoke:web:app` and
credited the shared prod-web-server helper with three consumers. Adds the new
smoke to both, plus its own entry alongside the sibling smokes: what it drives,
why the not-negotiated half is the load-bearing one, and the two mechanics that
are easy to get wrong (the tabs are a SegmentedControl with no `role="tab"`,
and the prompt string also appears in the hidden Protocol payload).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:26:11 -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