feat(auth): give the secrets file a real cross-process lock (#2082)
#1950 shipped FileSecretStore without cross-process mutual exclusion, by decision: an earlier revision hand-rolled a `mkdir` election with an owner stamp, a heartbeat and a stale-takeover, and three consecutive review rounds found a real race in it. The last one is not closable with what Node exposes — claiming a stale lock atomically needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`) — so it was replaced with optimistic verify-and-retry and the residual documented. #2082 settles that as "borrow, don't hand-roll". `proper-lockfile` is what npm itself locks with, and stale-takeover is precisely the problem it has already solved: it re-stats the lock directory after claiming it and gives the lock up when the mtime is not the one it wrote, so the loser of a takeover race releases instead of proceeding. - core/auth/node/file-lock.ts: `withSecretFileLock`. `realpath: false` so a file can be locked into existence (the very first `set` has no `secrets.json`, and the library's default resolves through `fs.realpath`); a warning in place of the library's `onCompromised`, which throws from a timer and would take the session down; and a degrade-with-one-warning path rather than a throw when no lock can be taken — this store exists for boxes missing the usual mechanism (#1848, #1905) and must not gain a new way to be unavailable. - FileSecretStore.mutate holds it across the whole read-modify-write. The optimistic verify stays underneath and is not redundant: a lock is advisory between the processes that take it, so the verify covers a writer outside this codebase and covers the degrade path. The in-process queue stays too, and gains a second job — proper-lockfile is not reentrant, so serializing per path keeps ELOCKED meaning "another process". - absorbFileSecretsIntoKeyring takes the same lock around orphan adoption and the atomic claim, behind a lock-free `readdir` fast path so the common startup (keychain available, no file ever written) neither creates a lock directory nor warns about one it could not create. proper-lockfile is a root `dependency` per the placement rule, and is named in all three bundler `external` lists: tsup externalizes what the *client's* manifest declares, so a root-only CJS package was being inlined into the ESM bundles, leaving esbuild's `Dynamic require of "path" is not supported` shim that killed `--cli` at import time. That rule was undocumented; it is now in AGENTS.md beside the placement rule that creates it, and mirrored into .github/copilot-instructions.md. Tests: 7 new in file-lock.test.ts driving a real second process (the existing suite structurally cannot — `serialize` orders in-process callers before the lock sees them), plus two for the migration fast path. 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>
This commit is contained in:
parent
7701c68028
commit
04dbc36544
1 changed files with 718 additions and 90 deletions
|
|
@ -54,6 +54,7 @@ import {
|
|||
readSecretFilePermissions,
|
||||
tightenSecretFilePermissions,
|
||||
} from "./file-secret-store.js";
|
||||
import { withSecretFileLock } from "./file-lock.js";
|
||||
import {
|
||||
KeyringSecretStore,
|
||||
parseAccount,
|
||||
|
|
@ -532,51 +533,76 @@ export async function absorbFileSecretsIntoKeyring(
|
|||
): Promise<void> {
|
||||
const filePath = defaultSecretFilePath();
|
||||
|
||||
// A crash between the claim and the delete leaves only
|
||||
// `secrets.json.migrating-<pid>`. Checking the canonical path alone then
|
||||
// reports "nothing to migrate", the keychain is selected, and every stored
|
||||
// credential silently disappears — the claim protecting the delete having
|
||||
// introduced a way to lose everything. Adopt any orphan first.
|
||||
await recoverOrphanedSnapshots(filePath);
|
||||
|
||||
if (!fsSync.existsSync(filePath)) return;
|
||||
|
||||
// **Claim the file atomically before reading it.** Comparing its contents
|
||||
// immediately before `rm` narrowed the window and could not close it: a
|
||||
// writer that completes a `set` after the comparison and before the delete
|
||||
// has its write verified, reports success, and then loses it — a *later*
|
||||
// successful write destroyed, which is worse than the optimistic-write
|
||||
// residual and, unlike that one, fixable here.
|
||||
// Cheap, lock-free "is there anything at all to do". Taking the lock first
|
||||
// would mean every startup on the overwhelmingly common path — a keychain
|
||||
// is available and no file was ever written — creates and removes a lock
|
||||
// directory, and on a box whose storage dir does not exist yet the lock
|
||||
// cannot be created at all, so `withSecretFileLock` would warn about
|
||||
// unprotected writes on every single run with nothing to protect.
|
||||
//
|
||||
// `rename` is atomic and leaves the live path free. Everything after this
|
||||
// point operates on a snapshot nobody else can reach, and a writer that
|
||||
// recreates `secrets.json` in the meantime is simply untouched — its file
|
||||
// is a different one, and the next run migrates it.
|
||||
//
|
||||
// The staged name carries the pid so two Inspectors starting together
|
||||
// cannot claim the same destination; whichever wins the rename does the
|
||||
// migration and the loser sees ENOENT and returns.
|
||||
// A per-attempt nonce, not just the pid. `recoverOrphanedSnapshots`
|
||||
// deliberately leaves an orphan in place when a live `secrets.json` also
|
||||
// exists — and a pid-only name is reusable across restarts (pid 1 on every
|
||||
// container start), so the next claim would `rename` straight over that
|
||||
// orphan and permanently discard secrets it may uniquely hold.
|
||||
const staged = `${filePath}.migrating-${process.pid}-${randomUUID()}`;
|
||||
try {
|
||||
await fs.rename(filePath, staged);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
// Only ENOENT means someone else claimed it. Anything else — EACCES on
|
||||
// the directory, EROFS — leaves the file exactly where it is, and
|
||||
// returning quietly would select the keychain while file-backed
|
||||
// secrets sit there unreadable by anything, with nothing said.
|
||||
console.warn(
|
||||
`\n[mcp-inspector] Could not claim the secrets file at ${filePath} for migration into the OS keychain (${code ?? "unknown error"}), so it has been left in place. Its secrets are not visible to this session.`,
|
||||
);
|
||||
// Racy by construction, and that is fine: it can only be wrong by saying
|
||||
// "nothing here" about a file created a moment later, which is a file the
|
||||
// next run migrates — the same outcome as a writer that recreates the path
|
||||
// after the claim below. Everything that *acts* re-checks under the lock.
|
||||
if (!(await anythingToMigrate(filePath))) return;
|
||||
|
||||
// Orphan adoption and the claim below both move the live path around, so
|
||||
// they run under the same cross-process lock a `set` takes (#2082) —
|
||||
// otherwise a concurrent writer's atomic rename can land between the two
|
||||
// and be adopted, claimed, or clobbered depending on the interleaving.
|
||||
// The lock is released before the hand-off: that part reads a snapshot
|
||||
// nobody else can reach, and holding it across a keychain round-trip per
|
||||
// secret would block every writer for the duration of a migration.
|
||||
const claimed = await withSecretFileLock(filePath, async () => {
|
||||
// A crash between the claim and the delete leaves only
|
||||
// `secrets.json.migrating-<pid>`. Checking the canonical path alone then
|
||||
// reports "nothing to migrate", the keychain is selected, and every stored
|
||||
// credential silently disappears — the claim protecting the delete having
|
||||
// introduced a way to lose everything. Adopt any orphan first.
|
||||
await recoverOrphanedSnapshots(filePath);
|
||||
|
||||
if (!fsSync.existsSync(filePath)) return null;
|
||||
|
||||
// **Claim the file atomically before reading it.** Comparing its contents
|
||||
// immediately before `rm` narrowed the window and could not close it: a
|
||||
// writer that completes a `set` after the comparison and before the delete
|
||||
// has its write verified, reports success, and then loses it — a *later*
|
||||
// successful write destroyed, which is worse than the optimistic-write
|
||||
// residual and, unlike that one, fixable here.
|
||||
//
|
||||
// `rename` is atomic and leaves the live path free. Everything after this
|
||||
// point operates on a snapshot nobody else can reach, and a writer that
|
||||
// recreates `secrets.json` in the meantime is simply untouched — its file
|
||||
// is a different one, and the next run migrates it.
|
||||
//
|
||||
// The staged name carries the pid so two Inspectors starting together
|
||||
// cannot claim the same destination; whichever wins the rename does the
|
||||
// migration and the loser sees ENOENT and returns.
|
||||
// A per-attempt nonce, not just the pid. `recoverOrphanedSnapshots`
|
||||
// deliberately leaves an orphan in place when a live `secrets.json` also
|
||||
// exists — and a pid-only name is reusable across restarts (pid 1 on every
|
||||
// container start), so the next claim would `rename` straight over that
|
||||
// orphan and permanently discard secrets it may uniquely hold.
|
||||
const staged = `${filePath}.migrating-${process.pid}-${randomUUID()}`;
|
||||
try {
|
||||
await fs.rename(filePath, staged);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
// Only ENOENT means someone else claimed it. Anything else — EACCES on
|
||||
// the directory, EROFS — leaves the file exactly where it is, and
|
||||
// returning quietly would select the keychain while file-backed
|
||||
// secrets sit there unreadable by anything, with nothing said.
|
||||
console.warn(
|
||||
`\n[mcp-inspector] Could not claim the secrets file at ${filePath} for migration into the OS keychain (${code ?? "unknown error"}), so it has been left in place. Its secrets are not visible to this session.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return staged;
|
||||
});
|
||||
if (claimed === null) return;
|
||||
const staged = claimed;
|
||||
|
||||
const file = new FileSecretStore({ filePath: staged });
|
||||
// True only when every value reached the keychain *and* there was
|
||||
|
|
@ -625,6 +651,28 @@ export async function absorbFileSecretsIntoKeyring(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there a `secrets.json`, or a snapshot orphaned by an interrupted
|
||||
* migration, worth taking the lock for?
|
||||
*
|
||||
* One `readdir` rather than a `stat` of the canonical path: an orphan is the
|
||||
* case where the live file is *absent* and there is still everything to
|
||||
* migrate, so checking only `secrets.json` would skip the recovery that
|
||||
* exists because skipping it loses every stored credential.
|
||||
*/
|
||||
async function anythingToMigrate(filePath: string): Promise<boolean> {
|
||||
const base = path.basename(filePath);
|
||||
try {
|
||||
return (await fs.readdir(path.dirname(filePath))).some(
|
||||
(name) => name === base || name.startsWith(`${base}.migrating-`),
|
||||
);
|
||||
} catch {
|
||||
// No storage directory yet — the first run on a fresh install, and the
|
||||
// path this check exists to keep quiet.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a snapshot left behind by a process that died mid-migration.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue