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>
This commit is contained in:
cliffhall 2026-08-22 19:09:16 -04:00
commit bc4b774d80
6 changed files with 257 additions and 42 deletions

View file

@ -52,7 +52,7 @@ export const EncryptedFile: Story = {
// and stays readable in the button's accessible name.
await expect(
canvas.getByRole("button", {
name: "Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
name: "Secrets: Encrypted file. Owner-only permissions. Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
}),
).toBeInTheDocument();
},

View file

@ -45,9 +45,12 @@ describe("SecretStorageFooter", () => {
"Secrets: Encrypted file. Owner-only permissions.",
);
expect(band).toHaveAttribute("data-tone", "neutral");
// The accessible name must carry the *status* as well as the affordance:
// `aria-label` replaces descendant text, so labelling it only "Copy…"
// would announce the button and discard the warning the band exists for.
expect(
screen.getByRole("button", {
name: "Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
name: "Secrets: Encrypted file. Owner-only permissions. Copy secrets file path: /home/node/.mcp-inspector/secrets.json",
}),
).toBeInTheDocument();
});
@ -136,6 +139,19 @@ describe("SecretStorageFooter", () => {
);
});
it("announces the security status, not just the copy affordance", () => {
// The regression this pins: `aria-label` overrides the button's
// descendant text, so a screen-reader user heard "Copy secrets file
// path …" and lost "Plaintext file" entirely — the one sentence that
// should change their mind about typing a secret.
renderWithMantine(<SecretStorageFooter info={plaintextFile} />);
const button = screen.getByRole("button");
expect(button).toHaveAccessibleName(
/^Secrets: Plaintext file\. Owner-only permissions\./,
);
expect(button).toHaveAccessibleName(/Copy secrets file path/);
});
it("renders nothing when the backend didn't report a store", () => {
// A guessed answer under a secret field is worse than no answer, so the
// unknown case is silence rather than a default.

View file

@ -291,12 +291,19 @@ export function SecretStorageFooter({ info }: SecretStorageFooterProps) {
{({ copied, copy }) =>
band(
<CaveatTooltip label={copied ? "Path copied" : tip}>
{/* The accessible name carries the path itself: a screen-reader
user gets no benefit from a clipboard they cannot inspect, so
the one place the location is still *readable* is here. */}
{/* The accessible name has to carry **both** halves. `aria-label`
replaces the button's descendant text, so labelling it only
"Copy secrets file path …" announced the affordance and threw
away the security status — "Plaintext file", "Unreadable
file", "not owner-only" — which is the entire reason this band
exists. The status leads, because it is the part a user needs
before deciding to type a secret; the path follows, since a
clipboard is no use to someone who cannot inspect it. Leading
with the visible text also keeps the accessible name a
superset of it (WCAG 2.5.3). */}
<CopyTarget
onClick={copy}
aria-label={`Copy secrets file path: ${info.path}`}
aria-label={`Secrets: ${footerMessage(info)} Copy secrets file path: ${info.path}`}
>
{row}
</CopyTarget>

View file

@ -680,17 +680,32 @@ describe("readOnDiskEncryption", () => {
);
});
it("needs no passphrase — it reads the envelope, never the payload", async () => {
// A store whose key is wrong still has to be describable in the UI.
it("reports a file the current passphrase cannot open as unreadable, not encrypted", async () => {
// This asserted the opposite until round 12, and the reviewer was right
// to call it: structure is not access. A store with the wrong key — or
// none — reads `null` from every `get` and refuses every `set`, so
// describing the file as "encrypted" gives the quiet, healthy footer to
// precisely the state the unreadable one exists to warn about. The user
// then discovers it when their save fails.
const writer = new FileSecretStore({
filePath: filePath(),
passphrase: "right-key",
});
await writer.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
const keyless = new FileSecretStore({ filePath: filePath() });
expect(await keyless.readOnDiskEncryption()).toEqual({
state: "encrypted",
});
for (const wrong of [
new FileSecretStore({ filePath: filePath() }),
new FileSecretStore({ filePath: filePath(), passphrase: "wrong-key" }),
]) {
const state = await wrong.readOnDiskEncryption();
expect(state.state).toBe("unreadable");
expect(state.state === "unreadable" ? state.detail : "").toMatch(
/cannot be decrypted with the current MCP_INSPECTOR_SECRET_KEY/,
);
}
// And the store that *can* open it is still described as encrypted.
expect(await writer.readOnDiskEncryption()).toEqual({ state: "encrypted" });
});
});
@ -970,6 +985,36 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => {
},
"kdf N is 3, which is not a power of two above 1",
],
[
"an empty ciphertext",
{
version: 1,
encryption: "aes-256-gcm",
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 },
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.`,
},
"ciphertext is empty",
],
[
"a salt that is not base64",
{
version: 1,
encryption: "aes-256-gcm",
kdf: { algorithm: "scrypt", salt: "!!!", N: 16384, r: 8, p: 1 },
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
},
"kdf salt is missing or not base64",
],
[
"a non-positive KDF r",
{
version: 1,
encryption: "aes-256-gcm",
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 0, p: 1 },
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
},
"kdf r/p are 0/1, which are not positive integers",
],
[
"an unsupported KDF",
{
@ -1003,18 +1048,100 @@ describe("readOnDiskEncryption accepts a real envelope", () => {
});
});
describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
// The lock these replace was removed after three review rounds each found
// a real race in it; the last needed a compare-and-swap on a directory
// entry that Node does not expose. The store now lets writers collide and
// makes the loser notice — so these assert convergence, which is the
// property that actually matters, rather than mutual exclusion, which it
// no longer claims.
/**
* Write an entirely different map straight to disk, bypassing the store.
*
* This is the *only* way to model a second process here. `serialize` keys
* its queue on the resolved path, process-wide, so two `FileSecretStore`
* instances pointing at one file do **not** race — they take turns. An
* earlier version of these tests used two instances and believed it was
* exercising the retry; it was exercising the queue, and passed for a
* reason unrelated to the code under test.
*/
async function writeAsAnotherProcess(
target: string,
secrets: Record<string, string>,
): Promise<void> {
await fs.writeFile(
target,
JSON.stringify({ version: 1, encryption: "none", secrets }),
"utf-8",
);
}
it("two stores racing set on one file keep both entries", async () => {
// The classic lost update: both read the same map, both write. Whichever
// lands second clobbers the other's entry — and the clobbered writer's
// verifying read is what catches it and re-applies.
/** Inject one external write between the store's write and its verify. */
function injectOneExternalWrite(
store: FileSecretStore,
target: string,
secrets: Record<string, string>,
): void {
// Double cast: `writeMap` is private, so no single cast reaches it. Safe
// because the asserted shape is its real signature — a rename or
// signature change fails this line rather than silently detaching the
// stub. See the note on `clobberAfterEveryWrite` below.
const inner = store as unknown as {
writeMap: (m: Record<string, string>) => Promise<void>;
};
const realWrite = inner.writeMap.bind(store);
let done = false;
inner.writeMap = async (map: Record<string, string>) => {
await realWrite(map);
if (done) return;
done = true;
await writeAsAnotherProcess(target, secrets);
};
}
describe("descriptor and retry edge cases", () => {
it("reports a decrypt that succeeds but yields the wrong shape", async () => {
// Authentic ciphertext, right passphrase, wrong payload — so `readMap`
// throws something that is *not* a key mismatch, and the descriptor must
// surface that reason rather than the passphrase advice.
await writeEncryptedFixtureWithPayload(["not", "a", "map"], "right-key");
const store = new FileSecretStore({
filePath: filePath(),
passphrase: "right-key",
});
const state = await store.readOnDiskEncryption();
expect(state.state).toBe("unreadable");
expect(state.state === "unreadable" ? state.detail : "").toMatch(
/does not hold a secret map/,
);
});
it("retries when the verifying read itself fails, then reports the unreadable file", async () => {
// The verify can fail rather than mismatch — another writer replacing the
// file with something unparseable. That is not a clobber to re-apply
// onto, so the attempt is abandoned and the next one reports the real
// problem instead of looping silently.
const store = new FileSecretStore({ filePath: filePath() });
const inner = store as unknown as {
writeMap: (m: Record<string, string>) => Promise<void>;
};
const realWrite = inner.writeMap.bind(store);
inner.writeMap = async (map: Record<string, string>) => {
await realWrite(map);
await fs.writeFile(filePath(), "{ not json", "utf-8");
};
await expect(store.set("srv", "env:A", "1")).rejects.toBeInstanceOf(
SecretStoreUnavailableError,
);
});
it("getMany answers empty for a file that does not exist yet", async () => {
const store = new FileSecretStore({ filePath: filePath() });
expect(await store.getMany("srv", ["env:A", "env:B"])).toEqual({});
});
});
describe("in-process mutations are serialized per path", () => {
// Not a race, and the tests must not claim to be one. `serialize` is keyed
// on the resolved path process-wide, so two instances on one file take
// turns — which is a real property worth pinning, because it is what makes
// the in-process case correct without relying on the optimistic retry.
it("two stores writing one file keep both entries", async () => {
const a = new FileSecretStore({ filePath: filePath() });
const b = new FileSecretStore({ filePath: filePath() });
await Promise.all([a.set("srv", "env:A", "1"), b.set("srv", "env:B", "2")]);
@ -1025,8 +1152,6 @@ describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
});
it("keeps every entry when many independent stores write at once", async () => {
// Independent instances, so the in-process queue does not serialize them
// for us — each is its own writer with its own view of the file.
const stores = Array.from(
{ length: 8 },
() => new FileSecretStore({ filePath: filePath() }),
@ -1039,20 +1164,7 @@ describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
}
});
it("converges when the racing writers are encrypted", async () => {
// Same property with a scrypt derivation inside every attempt, which is
// where the window is widest.
const opts = { filePath: filePath(), passphrase: "hunter2" };
const a = new FileSecretStore(opts);
const b = new FileSecretStore(opts);
await Promise.all([a.set("srv", "env:A", "1"), b.set("srv", "env:B", "2")]);
const reader = new FileSecretStore(opts);
expect(await reader.get("srv", "env:A")).toBe("1");
expect(await reader.get("srv", "env:B")).toBe("2");
});
it("a racing delete and set both land", async () => {
it("orders a delete and a set on the same file", async () => {
const store = new FileSecretStore({ filePath: filePath() });
await store.set("srv", "env:OLD", "x");
@ -1067,6 +1179,62 @@ describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
expect(await reader.get("srv", "env:OLD")).toBe(null);
expect(await reader.get("srv", "env:NEW")).toBe("y");
});
});
describe("cross-process convergence (optimistic verify-and-retry)", () => {
// These drive the path the in-process queue cannot reach: a write landing
// between our write and our verifying read, as a second Inspector would
// produce it.
it("re-applies onto the other writer's result when the verify sees a mismatch", async () => {
const store = new FileSecretStore({ filePath: filePath() });
await store.set("srv", "env:EXISTING", "0");
injectOneExternalWrite(store, filePath(), {
"srv:env:OTHER": "from-another-process",
});
await store.set("srv", "env:MINE", "1");
// Our value survived *and* landed on top of theirs rather than replacing
// it — which is the whole claim: the clobbered writer repairs it.
const reader = new FileSecretStore({ filePath: filePath() });
expect(await reader.get("srv", "env:MINE")).toBe("1");
expect(await reader.get("srv", "env:OTHER")).toBe("from-another-process");
});
it("re-applies a delete against a concurrently rewritten file", async () => {
const store = new FileSecretStore({ filePath: filePath() });
await store.set("srv", "env:DOOMED", "x");
injectOneExternalWrite(store, filePath(), {
"srv:env:DOOMED": "x",
"srv:env:OTHER": "from-another-process",
});
await store.delete("srv", "env:DOOMED");
const reader = new FileSecretStore({ filePath: filePath() });
expect(await reader.get("srv", "env:DOOMED")).toBe(null);
expect(await reader.get("srv", "env:OTHER")).toBe("from-another-process");
});
it("converges through a scrypt derivation on every attempt", async () => {
// The encrypted path re-derives per attempt, which is where the retry is
// most expensive and the window widest.
const opts = { filePath: filePath(), passphrase: "hunter2" };
const store = new FileSecretStore(opts);
await store.set("srv", "env:EXISTING", "0");
// The intruder writes a *plaintext* envelope, as a differently
// configured process would; the store must still read it and re-apply.
injectOneExternalWrite(store, filePath(), {
"srv:env:OTHER": "from-another-process",
});
await store.set("srv", "env:MINE", "1");
const reader = new FileSecretStore(opts);
expect(await reader.get("srv", "env:MINE")).toBe("1");
expect(await reader.get("srv", "env:OTHER")).toBe("from-another-process");
});
// A writer that never wins. Simulated by replacing the file *behind* the
// store immediately after each write — with a raw `fs` write rather than a

View file

@ -365,6 +365,28 @@ export class FileSecretStore implements SecretStore {
// fail, which is the one thing this state exists to prevent.
const problem = encryptedEnvelopeProblem(parsed);
if (problem) return { state: "unreadable", detail: problem };
// Structure is not access. A well-formed envelope this passphrase
// cannot open reads as `null` from every `get` and refuses every
// `set` — so classifying it "encrypted" hands the quiet, healthy
// footer to the exact state the unreadable one promises to warn
// about, and the user finds out when their save fails. Authenticate
// it: decrypt and discard. Costs one scrypt derivation (~23ms) per
// descriptor build, which is once per `/api/config`, and buys the
// difference between describing the file and merely describing its
// shape.
try {
await this.readMap();
} catch (err) {
return {
state: "unreadable",
detail:
err instanceof SecretFileKeyMismatchError
? `it cannot be decrypted with the current ${SECRET_KEY_ENV}`
: err instanceof Error
? err.message
: String(err),
};
}
return { state: "encrypted" };
}
return {

View file

@ -250,9 +250,11 @@ Each server entry may carry these Inspector-extension fields at the top level:
- `oauth.clientId` / `oauth.clientSecret` / `oauth.scopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth` (the disk-side `oauth` object is lifted into the flat `oauthClientId` / etc. fields on `InspectorServerSettings` for the form).
- **First-connect contract**: settings apply on the _first_ outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. **TUI/CLI rehydration:** the shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.json`, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface. As of #1950 there are **three** implementations — `KeyringSecretStore`, `FileSecretStore`, `InMemorySecretStore` — chosen per run by `core/auth/node/secret-store-selection.ts`; the integration suite still injects one directly via `RemoteServerOptions.secretStore`.
- **Migration**: on every `GET /api/servers`, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into the keychain and rewrites the file with the stripped shape. The migration is idempotent — when the keychain already holds a value for `(serverId, field)`, the keychain wins and the disk plaintext is dropped unread. After the rewrite the disk file no longer contains the secret material.
- **Migration**: on every `GET /api/servers`, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into **the selected secret store** and rewrites the file with the stripped shape. The migration is idempotent — when the store already holds a value for `(serverId, field)`, the store wins and the disk plaintext is dropped unread.
- **Post-#1950 the rewrite is gated on durability.** The disk copy is stripped only once the value is somewhere that outlives the process (`secretStoreIsDurable`). Against the session-scoped in-memory store — the container fallback — stripping would trade a secret that survives restarts for one that dies with the process, and because this runs on an ordinary `GET`, merely opening the app would destroy it. The values are still loaded into the store, so the session behaves normally; only the delete is withheld, and a one-line warning says so.
- **The "keychain wins" lookup uses the strict read.** `get` is tolerant by contract, so a transient store failure answers `null` — and this branch _writes_ on `null` and then strips the disk copy, which would let an older on-disk value replace a newer stored one. `secretStoreGetStrict` throws instead, and the migration abandons rather than writing on an answer it cannot trust.
- **Linux without libsecret**: every `SecretStore` shares one _tolerance contract_ — `get` returns `null`, the destructive operations silently no-op, and only `set` hard-fails (with a `SecretStoreUnavailableError`, translated to a `503`). So no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) work normally on a box with no reachable keychain. **Since #1950 that is no longer the end of the story**: a host without a keychain now falls back to a file or in-memory store rather than being unable to persist a secret at all — see [Secret store selection (#1950)](#secret-store-selection-1950).
- **Migration tolerance**: when migration encounters `KeychainUnavailableError`, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action.
- **Migration tolerance**: when migration encounters a `SecretStoreUnavailableError` — `KeychainUnavailableError` is one subclass, and the file store raises its own for an unreadable file, a changed passphrase or a write failure — the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry, so installing libsecret (or restoring the passphrase) lifts the secrets on the next GET with no user action. Partial-migration semantics are deliberate: if `set` threw partway through the loop the handler returns the _original_ config rather than the partially-rewritten one, so the disk file stays intact and the idempotent store-wins branch absorbs the already-written entries on retry.
- **Write ordering on POST/PUT**: keychain writes happen before the disk write, and obsolete-field deletions happen after. The intent is that a `set` failure (the only hard-fail path) leaves both stores in their pre-write state — no half-applied entry on disk that would trap a retry POST at `409`, and no premature deletion of an obsolete field whose disk write later fails.
- **Id rename (`PUT` with new `id`)**: secrets are keyed by **server id** (the `mcpServers` map key), not URL. On rename the handler reads existing keychain entries for the **original** id (`expectedSecretFields` + `readKeychainEntriesFor`), merges them with any secrets in the PUT body (body wins on conflict), filters to fields the **new** on-disk entry still expects (so removed stdio env keys are not carried over), writes under the **new** id, updates disk, then `deleteAllForServer(originalId)`. This matters for config-only renames (e.g. Server Config modal) that do not re-send OAuth client secrets or stdio env values — those live only in the keychain after #1356. Covered by `servers-route.test.ts` (`PUT rename moves … when it exists only in the keychain` for OAuth and stdio env).
- **Out of scope for this PR**: the OAuth handshake itself still runs in the browser via the MCP SDK, so during the token exchange the secret transits the wire (browser → MCP SDK → OAuth provider's token endpoint). The on-disk win this PR delivers is that the secret is no longer in the shareable / symlinked `mcp.json` and is no longer the source-of-truth on the filesystem. Moving the token exchange to the Node side is tracked separately.