fix(auth): address Copilot review round 5, and land the impl its tests need

Repairs origin as well as answering the review. 2d2c787e committed the
`absorbFileSecretsIntoKeyring` tests without the implementation they
import, so the branch has been failing `tsc` since; this commit brings the
source across.

Round 5's four findings (two inline, two suppressed):

- **The stale-lock takeover was still racy.** Round 4 made *stealing*
  liveness-aware but left the takeover as remove-then-recreate, which is
  not an election: two waiters can both judge the same lock stale, the
  first removes it and takes a fresh one, and the second then removes
  *that* — both inside the section, which is the lost update the takeover
  exists to prevent. Claiming is now an atomic `rename` to a
  token-unique name, so exactly one waiter can take a given stale lock and
  the losers get ENOENT. A failed claim also falls through to the deadline
  instead of `continue`ing, so a takeover that can never succeed times out
  rather than spinning forever on a swallowed error.

- **Every `stat` failure was read as "no file yet".** An existing file we
  cannot inspect (EACCES on the directory, a mode-less filesystem) then
  produced no warning at all, and the footer went on stating owner-only
  permissions having verified nothing. There is now a third state,
  `unknown`, carried end to end: `permissionsUnknown` on the descriptor, a
  caveat that says the permissions could not be checked, a `warn` tone, and
  its own branch in the footer sentence — which otherwise would have
  claimed "Owner-only permissions." by omission, the same bug one layer up.

- **mountinfo decoded only `\040`.** The kernel escapes tab, newline and
  backslash too (`\011`, `\012`, `\134`), so a mounted path containing any
  of them was compared in its escaped form, matched nothing, and read as
  the container's writable layer — `memory` selected and persistence
  silently lost on a box that had arranged for it. Decoded as octal in
  general now, since the rule is octal-in-general and a future addition
  would otherwise reintroduce exactly this.

- **File-backed secrets vanished once a keychain appeared.** Save secrets
  without libsecret, install libsecret, and the next run selects the
  keychain and stops seeing them: still on disk, read by nothing, and
  nothing looks broken. `absorbFileSecretsIntoKeyring` hands them over on
  the run that finds a keychain, under the rules the two existing plaintext
  migrations already use — keychain wins on conflict, the file is removed
  only on complete success, and a file that cannot be read is never
  deleted, because that is precisely the case where the values would be
  lost.

Tests: a two-waiter race over one stale lock asserting a single holder
throughout; `unknown` from `readSecretFilePermissions` through the
descriptor to the footer sentence and tone; the hand-off's happy path,
keychain-wins, write-failure, undecryptable-file, empty-file,
malformed-key, and explicitly-configured-keyring cases; and six more
rejected descriptor shapes, including each file-only field on a non-file
kind. Full `npm run ci` green — 5,732 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 14:15:35 -04:00
commit decc673a5d
4 changed files with 458 additions and 26 deletions

View file

@ -382,8 +382,29 @@ export async function acquireLock(
}
if (code !== "EEXIST") return noop; // cannot lock here at all — see above
if (await canSteal(lockDir, ownerFile, staleMs)) {
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {});
continue;
// Claim the stale lock by *renaming* it, not by removing it.
//
// `rm` is not an election: two waiters can both judge the same lock
// stale, and by the time the second one runs, the first may already
// have removed it and created a fresh lock of its own — which the
// second then deletes, and both enter the section together. That is
// the lost update, reached through the takeover meant to prevent it.
//
// `rename` is atomic and single-winner: exactly one waiter moves the
// directory aside, and the losers get ENOENT and fall through to
// retry against whatever the winner does next. The unique
// destination (our token) also means two winners of *different*
// stale locks cannot collide.
const claimed = `${lockDir}.stale-${token}`;
try {
await fs.rename(lockDir, claimed);
await fs.rm(claimed, { recursive: true, force: true });
continue;
} catch {
// Lost the election, or the rename failed outright. Either way,
// fall through to the deadline check rather than looping — a
// takeover that can never succeed must time out, not spin.
}
}
if (Date.now() >= deadline)
throw new SecretFileLockTimeoutError(filePath);
@ -566,6 +587,25 @@ export class FileSecretStore implements SecretStore {
* throw for "there is a file and we cannot read it" — which must never be
* flattened into an empty map, or `set` would cheerfully overwrite it.
*/
/**
* Every secret in the file, keyed by `${serverId}:${field}` — `null` when
* there is no file.
*
* Exists for one caller: the hand-off performed when a keychain becomes
* available on a box that had been falling back to a file (see
* `absorbFileSecretsIntoKeyring`). Nothing in the normal request path
* enumerates secrets, and deliberately so — this is the widest possible
* read of the store and it should stay a one-purpose seam rather than a
* general convenience.
*
* Throws for the same reasons `get` swallows: an unreadable or
* undecryptable file must not present as "no secrets", because the caller
* would conclude there was nothing to migrate and move on.
*/
async readAll(): Promise<Record<string, string> | null> {
return this.serialize(() => this.readMap());
}
private async readMap(): Promise<Record<string, string> | null> {
const raw = await readStoreFile(this.filePath);
if (raw === null) return null;
@ -830,10 +870,19 @@ export async function readSecretFilePermissions(
const mode = (await fs.stat(filePath)).mode & 0o777;
if (mode === 0o600) return { state: "ok" };
return { state: "loose", mode };
} catch {
// No file yet — the common first run. `writeStoreFile` creates it 0600,
// so there is nothing to warn about.
return { state: "absent" };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
// No file yet — the common first run. `writeStoreFile` creates it
// 0600, so there is nothing to warn about.
return { state: "absent" };
}
// The file exists and we could not inspect it (EACCES on the directory,
// a permission-less filesystem). Collapsing this into "absent" is what
// makes it dangerous: the descriptor then omits the warning and the
// footer goes on stating mode 0600 as fact, having verified nothing.
// "Unknown" is the honest third answer.
return { state: "unknown", detail: code ?? "unknown error" };
}
}
@ -846,4 +895,6 @@ export async function readSecretFilePermissions(
export type SecretFilePermissionState =
| { state: "ok" }
| { state: "absent" }
| { state: "loose"; mode: number };
| { state: "loose"; mode: number }
/** The file is there and its mode could not be read — see above. */
| { state: "unknown"; detail: string };

View file

@ -40,6 +40,7 @@
*/
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
type SecretStorageInfo,
@ -54,6 +55,7 @@ import {
} from "./file-secret-store.js";
import {
KeyringSecretStore,
parseAccount,
probeKeyringAvailable,
secretStoreIsDurable,
SessionSecretStore,
@ -186,10 +188,31 @@ function nearestExisting(dir: string): string | null {
* `null` where that file cannot be read (macOS, Windows, a `/proc`-less
* container).
*
* Field 5 of each line is the mount point, with spaces escaped as `\040`.
* `/` is dropped: in a container that is the writable layer, which is
* exactly what the durability question is asking *against*.
* Field 5 of each line is the mount point, octal-escaped. `/` is dropped:
* in a container that is the writable layer, which is exactly what the
* durability question is asking *against*.
*/
/**
* Undo the kernel's octal escaping of a mountinfo path.
*
* The field is space-delimited, so `show_mountinfo` escapes anything that
* would break parsing — space (`\040`), tab (`\011`), newline (`\012`) and
* the backslash itself (`\134`). Decoding only the space left a mounted
* path containing any of the others compared in its escaped form, so it
* matched nothing, and a genuinely mounted volume read as the container's
* writable layer: `memory` selected, persistence silently lost on a box
* that had arranged for it.
*
* Handled as one general `\ooo` pass rather than four literals, since the
* escaping rule is octal-in-general and a future addition would otherwise
* reintroduce exactly this bug.
*/
function unescapeMountPoint(point: string): string {
return point.replace(/\\(\d{3})/g, (_match, octal: string) =>
String.fromCharCode(parseInt(octal, 8)),
);
}
function readMountPoints(): string[] | null {
try {
return fsSync
@ -197,7 +220,7 @@ function readMountPoints(): string[] | null {
.split("\n")
.map((line) => line.split(" ")[4])
.filter((point): point is string => Boolean(point))
.map((point) => point.replace(/\\040/g, " "))
.map(unescapeMountPoint)
.filter((point) => point !== "/");
} catch {
return null;
@ -325,6 +348,7 @@ async function describeFileStore(
path: filePath,
plaintext,
...(perms.state === "loose" ? { looseMode: perms.mode } : {}),
...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}),
// Only meaningful while the two disagree; omitted otherwise so the
// payload does not carry a flag that says nothing.
...(plaintext && store.encrypted ? { pendingEncryption: true } : {}),
@ -385,11 +409,18 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> {
const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]);
if (configured) {
const result = await buildStore(configured, "configured");
if (configured === "keyring") {
await absorbFileSecretsIntoKeyring(result.store);
}
warnAboutSecretStorage(result.info);
return result;
}
const probe = await probeKeyringAvailable();
if (probe.available) return buildStore("keyring", "default");
if (probe.available) {
const result = await buildStore("keyring", "default");
await absorbFileSecretsIntoKeyring(result.store);
return result;
}
const kind = chooseFallbackKind({
container: isContainer(),
@ -402,6 +433,78 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> {
return resolved;
}
/**
* Hand a leftover secrets file over to the keychain, once one exists.
*
* The transition this closes: a box with no libsecret falls back to
* `secrets.json` and the user saves an OAuth client secret there. They
* install libsecret. The next run probes successfully, selects the keychain
* — and every value they saved becomes invisible, because nothing reads the
* file any more. The data is still on disk, which makes it worse rather
* than better: nothing looks broken, the secrets have simply gone, and the
* file sits there as an unexplained leftover.
*
* The rules are the ones the two existing plaintext migrations already use,
* for consistency rather than novelty:
*
* - **The keychain wins on conflict.** An account already present there is
* left alone; the file's value is treated as the older copy.
* - **The file is removed only on complete success.** A partial hand-off
* leaves it exactly as it was, so the next run retries and the
* keychain-wins rule silently absorbs whatever already made it across.
* - **An unreadable file is not an empty one.** If it cannot be decrypted
* (the passphrase changed, or is now unset) this reports and returns
* rather than deleting it — the whole point is not to lose the values.
*
* Never throws. A keychain that fails mid-hand-off leaves the file intact,
* which is the safe state, and the user still gets a working session.
*/
export async function absorbFileSecretsIntoKeyring(
keyring: SecretStore,
): Promise<void> {
const filePath = defaultSecretFilePath();
if (!fsSync.existsSync(filePath)) return;
const file = new FileSecretStore({ filePath });
let entries: Record<string, string> | null;
try {
entries = await file.readAll();
} catch (err) {
console.warn(
`\n[mcp-inspector] The OS keychain is available again, but the secrets file at ${filePath} could not be read, so its contents were left there: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
if (!entries || Object.keys(entries).length === 0) return;
try {
for (const [account, value] of Object.entries(entries)) {
const parsed = parseAccount(account);
if (!parsed) continue;
const existing = await keyring.get(parsed.serverId, parsed.field);
if (existing === null) {
await keyring.set(parsed.serverId, parsed.field, value);
}
}
} catch (err) {
console.warn(
`\n[mcp-inspector] Could not move the secrets file at ${filePath} into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
try {
await fs.rm(filePath, { force: true });
console.warn(
`\n[mcp-inspector] The OS keychain is available again. Secrets kept in ${filePath} while it was not have been moved into the keychain, and the file has been removed.`,
);
} catch {
// Every value is in the keychain; only the now-redundant file remains.
// Not worth failing startup over, and the keychain-wins rule makes the
// next attempt harmless.
}
}
/**
* The active store's descriptor — for the banner and `GET /api/config`.
*

View file

@ -72,6 +72,17 @@ export interface SecretStorageInfo {
* and the browser had no way to know.
*/
looseMode?: number;
/**
* Set when the secrets file exists and its mode could not be read at all
* — `EACCES` on the containing directory, or a filesystem with no POSIX
* modes. Carries the errno so the message can say why.
*
* Distinct from {@link looseMode}, and the distinction matters: that one
* says "we looked and it is wrong", this one says "we could not look".
* Collapsing the second into silence is what let the footer keep stating
* mode 0600 as fact having verified nothing.
*/
permissionsUnknown?: string;
/**
* True when secrets outlive the process. False only for `memory`, and
* it is the *promise* being made rather than an implementation detail:
@ -116,6 +127,9 @@ export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone {
// An encrypted file that anyone can read is still worth flagging: the
// passphrase is the only thing standing between a reader and the values.
if (info.looseMode !== undefined) return "warn";
// Unverified is not the same as fine. The quiet tone here would read as a
// confirmation we never made.
if (info.permissionsUnknown !== undefined) return "warn";
return "neutral";
}
@ -137,6 +151,12 @@ export function secretStorageCaveat(
const mode = info.looseMode.toString(8).padStart(4, "0");
return `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`;
}
// Ordered with the permission problems, above encryption, for the same
// reason: not knowing whether others can read the file is a question about
// exposure, and it should not be answered by a reassuring silence.
if (info.permissionsUnknown !== undefined) {
return `The secrets file's permissions could not be checked (${info.permissionsUnknown}), so it is not known whether others can read it.`;
}
if (info.plaintext) {
return info.pendingEncryption
? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved."

View file

@ -111,6 +111,12 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined {
if (v.looseMode !== undefined && typeof v.looseMode !== "number") {
return undefined;
}
if (
v.permissionsUnknown !== undefined &&
typeof v.permissionsUnknown !== "string"
) {
return undefined;
}
} else if (
// File-only fields on a keychain or memory descriptor mean the payload
// was not built by a backend we understand. Rejecting is cheap and the
@ -118,7 +124,8 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined {
v.path !== undefined ||
v.plaintext !== undefined ||
v.pendingEncryption !== undefined ||
v.looseMode !== undefined
v.looseMode !== undefined ||
v.permissionsUnknown !== undefined
) {
return undefined;
}