fix(auth): address Copilot review round 1 on the secrets-file lock (#2082)
- Create the storage directory before locking. `writeStoreFile` creates it, but from inside the locked section — so on a fresh install, where `~/.mcp-inspector` does not exist, proper-lockfile failed ENOENT and every *first* save degraded to an unlocked write with a warning. That is the save most likely to be racing another (two Inspectors started together both reach it), so the lock was absent from precisely the interleaving it exists to close. - `anythingToMigrate` no longer reads every `readdir` failure as "nothing to migrate". Only ENOENT/ENOTDIR proves the fresh-install case; a directory that denies listing can still permit access to the known `secrets.json`, and returning false there selected the keychain and left those secrets invisible with nothing said. Other errors fall through to the under-lock checks, which is what happened before the fast path existed. - Pin `proper-lockfile` to the repo-root install in `vitest.shared.mts`, beside express and yaml: it is reached only through root-owned `core/`. Resolution already finds the root copy since no client declares it; the pin is what stops that depending on it never arriving as some client's transitive dependency, which would give a test two copies of a module whose whole job is one registry of held locks. - Make the cross-process test prove `mutate` takes the lock. The previous one asserted only that both secrets survived, which the optimistic verify delivers with the lock removed entirely. It now asserts the parent's `set` has *not* settled while a child holds the lock — an observation only a real lock produces. Verified by removing `withSecretFileLock` from `mutate`: three tests fail, where none did before. The degrade test's "lock cannot be created" case is now a path whose parent is a *file* rather than a missing directory, since a missing directory is no longer that case. It also fails identically for root, so it cannot pass locally and flake in a container. 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
04dbc36544
commit
16613b115d
1 changed files with 120 additions and 16 deletions
|
|
@ -666,10 +666,18 @@ async function anythingToMigrate(filePath: string): Promise<boolean> {
|
|||
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;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
// Only "there is no directory" proves there is nothing to migrate — the
|
||||
// first run on a fresh install, and the path this check exists to keep
|
||||
// quiet. Every other failure (EACCES, EPERM, EMFILE) means the directory
|
||||
// is there and could not be *listed*, which is not the same as empty: a
|
||||
// directory can deny listing while still permitting access to the known
|
||||
// `secrets.json` path, so returning false would select the keychain and
|
||||
// leave those secrets invisible with nothing said. Fall through instead
|
||||
// and let the under-lock `existsSync` and claim decide, which is what
|
||||
// happened before this fast path existed.
|
||||
return code !== "ENOENT" && code !== "ENOTDIR";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue