3,716 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
1aa1f5a0ca |
docs(web): attach the footer doc blocks to the functions they describe
No behavior change.
Adding `filePermissionsSentence` in
|
||
|
|
decc673a5d |
fix(auth): address Copilot review round 5, and land the impl its tests need
Repairs origin as well as answering the review.
|
||
|
|
71cb7f3eae |
feat(web): state the file store as a sentence, and copy its path on click
The footer's file states now read "Secrets: Plaintext file. Owner-only
permissions." and "Secrets: Encrypted file. Owner-only permissions.",
and the band itself became the copy affordance for the path.
Three things worth reviewing:
- The path is no longer printed on the band. It goes to the clipboard
instead, which means the only place it stays *readable* is the
button's accessible name — so that carries it in full
("Copy secrets file path: /home/node/.mcp-inspector/secrets.json")
rather than a generic "Copy". A clipboard is no use to a screen-reader
user who cannot inspect it.
- "Owner-only permissions." is a claim of fact, so it is made only when
the mode was verified as 0600. `looseMode` is set exactly when it is
something else and could not be tightened, and the message says
"Mode 0644 — not owner-only." there. A footer that asserted the
opposite of the truth about a secrets file is the one failure this
band exists to prevent, so it has its own test.
- The footer phrases its own copy rather than reusing
`secretStorageLabel` for the file case: that label is parenthetical
("File (unencrypted)") because the startup banner appends
" at <path>" to it. The tone and the underlying facts still come from
the shared helpers, so the two surfaces cannot disagree about
anything that matters. Memory's line is still derived from the shared
caveat.
The click target is an `UnstyledButton` filling the band edge to edge —
a real control, reachable by keyboard and announced as a button, rather
than a click handler on a div. Padding moved from the band to the inner
row so the button has no dead margins.
Signed-off-by: cliffhall <cliff@futurescale.com>
|
||
|
|
2d2c787e22 |
style(web): tighten the in-memory caveat and punctuate the footer
Reads "Memory (this session only): Secrets are not written anywhere and are lost on exit." — shorter tail than "lost when the Inspector exits", and a colon separating the store from what follows it. The colon is added at the footer's composition, not inside `secretStorageLabel`. The label is also consumed alone by `secretStorageSummary`, which appends " at <path>" for a file-backed store, so a colon baked into the label would print "File (encrypted): at /path" in the startup banner. It renders only when a detail actually follows. The caveat string is shared with the banner's warning line, so the terminal picks up the shorter wording too. Signed-off-by: cliffhall <cliff@futurescale.com> |
||
|
|
0cb59cf3fd |
fix(auth): stop heartbeating a lock that is no longer ours
Found on a re-read of the round-4 liveness work, not by review. The heartbeat refreshed the lock directory's mtime unconditionally. After a steal — possible when this process looked dead, or across hosts where the pid check cannot apply — the directory belongs to someone else, and touching it keeps *their* lock looking alive. If they then die, the stale timestamp nobody is maintaining is being maintained by us, and a third writer can never break in: a stale lock turned unbreakable, which is the permanent outage the stealing exists to avoid. The heartbeat now re-reads the owner stamp and clears itself when the token no longer matches, so it only ever refreshes a lock we still hold. The release path already checked the token; this closes the same hole on the other side. Tested by stamping a different owner onto a held lock and aging it: the mtime stays old across several heartbeat intervals, and release leaves the thief's lock intact. Signed-off-by: cliffhall <cliff@futurescale.com> |
||
|
|
050556e1a7 |
fix(auth): address Copilot review round 4 on the secret-store PR
Four findings (two inline, two suppressed), all taken. Three are the lock, which is where the remaining sharp edges were. - **A lock could be created and then never stamped.** If `mkdir` succeeded but writing the owner token failed, the catch treated it as "cannot lock here", returned the no-op release, and left the directory behind. The mutation then ran unlocked *and* every later writer queued behind an orphan nobody could deliberately release, until it aged out. The acquisition now tracks whether it created the directory, removes it on a failed stamp, and refuses rather than entering the section unlocked. - **An old mtime was being treated as proof the owner was dead.** It is not: a holder that is SIGSTOPed, starved by a blocked event loop, or on a machine that suspended is alive and not running, and expiring it on elapsed time alone takes the lock out from under a live critical section — the lost update the lock exists to prevent, arrived at through the machinery meant to prevent it. The heartbeat narrowed the window; it could not close it, because the holder that most needs protecting is precisely the one not running its timers. The owner stamp now carries `pid` and `host`, and staleness only *opens* the question — on the same host a waiter asks the OS whether that pid still exists (`kill(pid, 0)`, which sends no signal; `EPERM` counts as alive, so a holder running as another user is never stolen from). Only a genuinely absent process is stolen from. Where the stamp cannot be trusted — another machine over a shared filesystem, or a missing/corrupt owner file — the mtime remains the answer, because the alternative is a lock nothing can ever break, turning one dead process into a permanent outage. - **Podman was classified as a host.** `/.dockerenv` is Docker's marker and rootless Podman has no equivalent of it, while its cgroup paths say `libpod-<id>.scope` rather than the literal "podman" the pattern looked for. Such a box fell back to a file in the ephemeral writable layer and reported it as durable — the wrong way round to be wrong. `/run/.containerenv` and `libpod` are both recognized now. - **Two tests floated a second `acquireLock`**, so the test could finish and `afterEach` could remove the temp directory while the acquisition was still running. Both hold and settle it now. Separately, a defect of my own found while re-reading round 3: `describeFileStore` called `tightenSecretFilePermissions`, so the function whose job is to *describe* the store issued a `chmod` on every `GET /api/config` — once per page load, on a file another process may be mid-write on. Split out a read-only `readSecretFilePermissions`; the repair stays at startup, where it happens once and its outcome is announced. Tests: a stale lock whose owner is alive is not stolen (and survives the attempt), one whose pid is gone is, one stamped by another host is; a failed ownership stamp reports and leaves nothing behind; Podman via both signals; and the read-only permission check leaves a 0644 file at 0644. Full `npm run ci` green — 5,711 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> |
||
|
|
6460d1804a |
style(web): bold the "Secrets:" prefix in the settings-modal footers
Weights the label so the band reads as a labelled fact rather than one run of prose — the eye lands on what the line is about before reading which store it names. Rendered as a `span` inside the existing `Text` rather than as a sibling in the `Group`: `Text` renders a `<p>`, so a nested one would be a `<p>` inside a `<p>` (invalid, and the browser closes the outer paragraph early), and a sibling would take the Group's `gap` and sit too far from the value it labels. `inherit` keeps it at the label's own size. Splitting the text is why the assertions move off `getByText`: it matches a string against an element's *direct* text nodes, so "Secrets: OS keychain" now reads as two fragments even though it renders as one line. The unit tests, both modal tests, and the four stories assert `toHaveTextContent` on the band instead, which reads the full subtree. Verified in the running dev server: prefix 700, store name 500, both at the same 12px. Signed-off-by: cliffhall <cliff@futurescale.com> |
||
|
|
b143dd6cbd |
fix(auth): address Copilot review round 3 on the secret-store PR
Nine findings (six inline, three suppressed), all taken. Two were serious. **The memory fallback made the migrations destructive.** Both migrations — mcp.json in the remote server, client.json in `node-persistence` — lift a plaintext secret off disk, write it to the secret store, and then delete it from the file. That trade is safe only while the store is at least as durable as the disk, which held by construction while `InMemorySecretStore` was a test double. Making it a production fallback broke it: on an unmounted container a plain `GET /api/servers` would have moved a user's existing secrets into RAM and lost them at exit — destroying data on a read. `SecretStore` gains an optional `isDurable()`, absent meaning durable, and both migrations withhold the disk delete when it answers false (the value is still loaded, so the session behaves normally). The production fallback is now a named `SessionSecretStore` rather than the bare `InMemorySecretStore`, so the suite's many doubles keep standing in for a *working keychain* — which is what they are there to be — and only code that deliberately chooses RAM in production says so. **Round 2's per-call descriptor never reached anyone.** Both web servers resolved it once at startup and baked it into `initialConfig`, which `/api/config` serves unchanged — so the freshness fix was inert for its only consumer. The route now takes a `secretStorageResolver` and calls it per request. The lock needed three more fixes, and they compound: - **Release removed whatever directory was at the path.** A holder whose work outran the stale threshold would delete the *next* owner's lock, letting a third writer into a section two processes believed they held. Ownership is now stamped with a random token and release only removes a lock still carrying it. - **The timeout fell through to an unlocked write** — under contention, the one moment another writer is provably active. It now throws `SecretFileLockTimeoutError`, which `set` is allowed to do. - **The first write of every process bypassed the lock**, because the secrets directory does not exist yet and `mkdir` failed ENOENT. The parent is created first. First writes are exactly when two processes are most likely to start together. Fixing the timeout exposed a fourth: with stale (10s) below timeout, a waiter stole from a holder that was merely *slow* rather than dead — reinstating the lost update. A holder now heartbeats its lock, so "stale" means the holder is gone, and the timeout is what a live-but-slow holder produces. Lock timings are injectable so the tests can exercise contention, staleness, and timeout without spending the real intervals. Also: the loose-permission state is carried into `SecretStorageInfo` as `looseMode` and rendered as the caveat (it outranks the encryption caveat — a file others can read is a live exposure, while "unencrypted" is a property of a file only its owner can open), rather than living in a startup log the browser cannot see; and `usableSecretStorage` validates `pendingEncryption` and `looseMode` as the optional booleans/numbers they are (`pendingEncryption: "false"` is truthy and would have printed advice that is the opposite of the truth) and rejects file-only fields on a keychain or memory descriptor. Tests: both migrations against a session store with a durable control alongside, so the guard is visibly the difference; `/api/config` serving a changing resolver and falling back without one; lock ownership after a steal, the timeout under a live holder, no-steal from a slow holder, and a locked first write; `looseMode` through the descriptor, the caveat ordering, and the tone; and five more rejected payload shapes. Full `npm run ci` green — 5,704 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> |
||
|
|
de7795ca54 |
fix(auth): address Copilot review round 2 on the secret-store PR
Seven findings, all taken. - **The descriptor went stale after the upgrading write.** Round 1 made it read the file instead of the policy, but it was still computed once and cached beside the selection — so the first `set` under a newly-set passphrase encrypted the file while `/api/config` kept serving "still unencrypted" for the rest of the session, describing bytes this very process had just changed. The *selection* stays cached (the banner, the payload, and the store doing the writing must agree on which store it is); the file descriptor is re-derived per call, which costs one read of a small file per config fetch. - **The mount check asked the wrong question.** Comparing a directory's `st_dev` against its parent's detects a mount *at* that path, but a volume mounted at `/home/node` leaves `/home/node/.mcp-inspector` an ordinary subdirectory sharing its parent's device — so a durable container setup was judged unmounted and demoted to session-only `memory`. It now reads `/proc/self/mountinfo` and asks whether the directory is *under* any mount other than `/`, which also catches the same-device bind mount no `st_dev` test can see. Where mountinfo is unreadable (macOS, Windows, a `/proc`-less container) it falls back to the device comparison, now against `/` rather than the parent. - **A decrypted payload was cast, not checked.** GCM proves the bytes are authentic, not that they parse to the right shape. An array passes `typeof === "object"`, takes the named assignment, and then `JSON.stringify` drops every named property of an array — so `set` would resolve having written a file without the secret it was handed. Silent loss on the one operation whose entire contract is that it does not lose things. Both branches now validate a non-array object of string values, and a shape refusal is deliberately not rethrown as a key mismatch, which would send the user to fix a working passphrase. - **The read-modify-write cycle was only serialized in-process.** A durable file is shared state and a second Inspector on the same box (a CLI run beside a web session) could read the same map and overwrite the first one's save, both reporting success. Mutations now take a `mkdir`-based lock. A lock left by a killed process is stolen after 10s — one `docker kill` must not brick secret writes permanently, which would be a worse failure than the race. If the lock cannot be created at all, the write proceeds unlocked rather than denying a real user a real secret to prevent a race with a process that may not exist. Reads do not lock: `writeStoreFile` is atomic, so a reader sees the old file or the new one, never a torn one. - **The permission repair was fire-and-forget while the docs promised 0600.** A file owned by another user, or on a read-only mount, stayed exposed while the footer described it as protected. It now verifies the resulting mode and warns, naming the actual mode, when it could not be tightened. - **`Group`'s theme carried a domain-named `secretStorageFooter` variant**, which AGENTS.md reserves for app-wide primitive styling. Renamed to the structural `stickyModalFooter`; the secret-storage meaning stays in the element that owns it. - The PR description still claimed the salt made a short passphrase safe, contradicting the corrected comment and README. Updated. Tests: both mount layouts that motivated the change (mount-at and mount-above) plus the sibling-prefix, space-escaped, first-run, and no-mountinfo cases; the array and non-string refusals on both the plaintext and the authentic-ciphertext paths (the latter through a hand-built fixture, since the store's own writer cannot produce that state); lock exclusion, stale-lock stealing, the un-lockable fallback, and two independent store instances racing; the permission report in all three states; and the descriptor no longer going stale within one process. Full `npm run ci` green — 5,688 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> |
||
|
|
3452a63101 |
fix(auth): address Copilot review on the secret-store PR
Six findings, all taken.
- The descriptor reported the *write policy*, not the file. Adding
`MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
file flipped `store.encrypted` immediately, so the banner and footer
claimed "File (encrypted)" while the existing secrets sat readable
until the next `set`. `FileSecretStore.readOnDiskEncryption()` now
reads the envelope (never the payload, so it needs no passphrase) and
the descriptor is built from that. The transitional state gets its own
`pendingEncryption` flag, which changes the advice without softening
the verdict — "set a passphrase" is the wrong thing to tell someone who
already has, so the caveat names the pending write instead.
- The secrets file ignored the configured storage directory. A container
mounting only that directory was judged unmounted, fell back to
`memory`, and lost secrets the user had arranged to keep.
`defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable
that already relocates OAuth tokens and `client.json` — between the
explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector`
default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no
such variable exists.)
- `version` was never checked before the plaintext branch, so a
`{ version: 2, encryption: "none" }` file was read and silently
rewritten as version 1, discarding whatever the newer format added —
the same destroy-to-satisfy-an-additive-request case the encrypted
branch already refuses. Checked first now, so the answer no longer
depends on which encryption mode the newer writer chose.
- The doc comment claimed a per-file salt made "a short, memorable value
safe to use". It does not: the salt defeats precomputation, not
guessing, and the scrypt cost is deliberately a few milliseconds
because it runs on every read and write. Both the comment and the
README now say to use a high-entropy value, generated rather than
chosen.
- `usableSecretStorage` validated only `kind`, so a partial
`{ kind: "file" }` was cast through and — because a missing `plaintext`
is falsy — rendered as the quiet, neutral *encrypted* file. That is the
confident half-answer the guard exists to reject, produced by the
absence of information rather than by any claim the backend made.
`reason`, `durable`, and each kind's own required fields are checked.
- The footer's inline `Tooltip` carried four static props; extracted to a
`CaveatTooltip` constant per the `.withProps()` rule.
Tests cover each: the policy/file disagreement in both directions and the
no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against
`MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes
survive it) split from the unrecognized-cipher refusal it used to be
conflated with, `readOnDiskEncryption` including the keyless and
unparseable cases, five new rejected descriptor shapes, and the changed
caveat. Full `npm run ci` green.
Signed-off-by: cliffhall <cliff@futurescale.com>
|
||
|
|
bc408f5869 |
feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
Closes #1950 Before this, a box with no reachable OS keychain — the published container (no D-Bus session, #1848), Android/Termux (no platform binary, #1905), a minimal Linux install without libsecret — could not persist an OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore` degraded into a store that silently holds nothing: `get` returned null, `delete` no-opped, and `set` hard-failed with a 503. That is the correct failure behavior, but it left the documented, supported way to run the Inspector with no secret persistence whatsoever. Adds the two missing implementations and, more importantly, the policy that picks one and the surfaces that say which was picked. - `FileSecretStore` — one `0600` JSON document at `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The whole map is encrypted as a unit rather than value-by-value so the account names — `${serverId}:${field}` — are hidden too; a per-value scheme would leave a readable index of which servers you hold a client secret for. A file that can no longer be decrypted reads as empty (matching the interface's read tolerance) but *refuses to be written*, because replacing a file of still-valid secrets to satisfy an additive request destroys data. - `secret-store-selection.ts` — the policy. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright; otherwise probe the keychain and, if unreachable, fall back loudly. The fallback is `memory` in a container whose secrets directory is not on a mount, and `file` otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `--rm` will discard. Cached per process so the banner, `/api/config`, and the store doing the writing cannot disagree. - Automatic, but never silent. The downgrade prints at startup, rides `GET /api/config`, and is named in a permanent 32px footer at the bottom of both dialogs that accept a secret. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is the thing a user dismisses before doing the work it describes. The plaintext and memory cases render their caveat inline in a warning tone — a warning you must hover to discover is not one. - `SecretStoreUnavailableError` is now the base every store's `set` throws, so the routes translate one condition into a 503 instead of matching `KeychainUnavailableError` by name. Docs: the container section of the root README gains a table of which store you get and how to make secrets survive, plus the `MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules. Tests: the existing interface contract block is extracted to `secretStoreContract.ts` and run against all three implementations, so none can drift on the availability contract. `FileSecretStore` is tested against a real temp filesystem — mode, cross-instance round-trip, that the ciphertext does not contain the plaintext, the read-modify-write race, and every refusal path. Full `npm run ci` passes, per-file gate included. Signed-off-by: cliffhall <cliff@futurescale.com> |
||
|
|
43525d9ea2 |
Merge pull request #2070 from modelcontextprotocol/v2/ci/2069-drop-redundant-pr-checkout
ci: drop the redundant PR-head checkout from claude.yml |
||
|
|
f8e97c99a1 |
ci: drop the redundant PR-head checkout from claude.yml
Code scanning alert 71 (actions/untrusted-checkout/high) has stayed open against main at the `Checkout PR branch` step — the same step #1966 added the `is_fork == 'false'` guard to. The rule is syntactic, so it cannot see that guard, the job-level author_association check, or the repo's `collaborators_only` fork-PR policy; no hardening around the step clears it while the step exists. The step turns out to be redundant anyway. claude-code-action fetches and checks out the PR branch itself, and its own docs name a bare base-ref checkout as the preferred pattern — which is what upstream's own claude.yml does. Our lookup-then-checkout pair is vestigial from the v1 example workflow inherited in #1869. So: delete the PR-head checkout, make the base checkout unconditional, and drop the now-unused `sha` output. The `is_fork` gate on Run Claude Code stays, and is now the only thing keeping fork code out of the workspace — the action fetches a cross-repository head via refs/pull/N/head on its own. Closes #2069 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> |
||
|
|
86d5f58ec8 |
Merge pull request #2054 from modelcontextprotocol/v2/chore/milestone-merge-v2.3.0
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
chore: merge v2/main for the v2.3.0 milestone release |
||
|
|
d87b34d466 | Merge remote-tracking branch 'origin/v2/main' into v2/chore/milestone-merge-v2.3.0 | ||
|
|
471b86535e |
Merge pull request #2061 from modelcontextprotocol/v2/chore/2058-nanoid-advisory
chore(deps): bump nanoid to 3.3.18 to clear the high-severity advisory |
||
|
|
180b397aec |
chore(deps): bump nanoid to 3.3.18 to clear the high-severity advisory
`npm audit --omit=dev` reported one high-severity finding in the production tree, on both `main` and `v2/main`: nanoid <3.3.18 — GHSA-2v37-7h3g-55p8 custom generators can loop indefinitely when size is zero It arrives transitively, via vite -> postcss -> nanoid. `postcss` depends on `nanoid@^3.3.11`, so the fix is an in-range lockfile bump in all five installs — no package.json changed. `npm audit --omit=dev` is now clean at the root and in each of the four client installs. Applied as a targeted `npm update nanoid` per install rather than `npm audit fix`. The latter reaches the same clean audit but re-resolves the whole tree, and here dragged in an unrelated esbuild downgrade (0.27.7 -> 0.27.2) across web/cli/tui — roughly 700 lines of lockfile noise for a one-package security patch. The issue also asked whether `vite` belongs in root `dependencies`, since that placement is why this advisory appears under `--omit=dev` at all. It does, and deliberately: `start-vite-dev-server.ts` imports `vite` and `@vitejs/plugin-react` at runtime for `--web --dev`, and `tsup.runner.config.ts` lists both as `external`, so a published install resolves them from the root manifest. Moving them to `devDependencies` would break `--web --dev` for consumers while passing every local check. Left in place, with the reasoning recorded in the README's dependency-placement paragraph so it need not be re-derived. Verified: `npm audit --omit=dev` reports 0 vulnerabilities in all five installs; `npm run ci` passes. Signed-off-by: cliffhall <cliff@futurescale.com> |