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>
This commit is contained in:
cliffhall 2026-08-22 20:31:03 -04:00
commit 52087f7739

View file

@ -498,7 +498,21 @@ export class FileSecretStore implements SecretStore {
if (!this.passphrase)
throw new SecretFileKeyMismatchError(this.filePath, false);
// Diagnose structure before blaming the passphrase. A truncated payload,
// a wrong-length IV or tag, or an out-of-range KDF parameter all fail
// below — and as a `SecretFileKeyMismatchError` they tell the user to
// restore a passphrase that cannot repair the file. The descriptor path
// already runs this check; the read path was still guessing.
const structural = encryptedEnvelopeProblem(parsed);
if (structural) {
throw new SecretStoreUnavailableError(
`The secrets file at ${this.filePath} is not readable: ${structural}. Restoring a passphrase will not repair it; the file itself is malformed.`,
);
}
const parts = decodeParts(parsed.data);
/* v8 ignore next -- @preserve: unreachable, `encryptedEnvelopeProblem`
above returns a reason for anything `decodeParts` would reject. Kept as
the type narrowing `parts` needs. */
if (!parts) throw new SecretFileKeyMismatchError(this.filePath, true);
try {
const key = await this.deriveKey(parsed.kdf);