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
3 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>