fix(auth): keep the keychain hand-off non-throwing, and stop overstating the wait (#2082)
Copilot review round 3. - **`absorbFileSecretsIntoKeyring` must never throw, and round 2 broke that.** Making `withSecretFileLock` reject on a held lock was right for `set` — a user is waiting on that value — and wrong here: this is awaited directly by both `resolveSecretStore` branches, so a stuck writer elsewhere on the box would have failed store resolution and with it the whole session. The lock failure is now caught, warned, and the file left for the next run. Covered by a test that holds the lock from another handle and asserts the hand-off resolves, the file survives, and the lock-specific warning is emitted (it asserts that message rather than the shared "left in place" tail, which the pre-existing claim-failure path also prints and would have let it pass without the lock ever being reached). - **The refusal message overstated how long it waited.** `retries * maxTimeout` reads 20s for a schedule that sums to 15.26s — the early attempts are the exponential ramp, not the cap. Replaced with `RETRY_BUDGET_MS`, computed from the schedule rather than written down, so it cannot drift from the thing it describes. `retry` applies no jitter by default, so it is exact rather than an estimate. - **The PR description still described the pre-round-2 design** — the single-winner claim and the ~3s backoff. Rewritten to match the code. Coverage: the round-3 changes cost `secret-store-selection.ts` two branches and it fell to 89.62%. Recovered honestly rather than by annotation — the `ENOTDIR` arm of `anythingToMigrate` now has a real test (a path whose parent is a file, the other shape of "nothing there"), and the new catch types its parameter `Error` instead of re-narrowing, since `withSecretFileLock` rejects only with the `SecretStoreUnavailableError` it constructs one call away. 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
dcd70e2f11
commit
833a6ba76b
1 changed files with 92 additions and 1 deletions
|
|
@ -684,6 +684,39 @@ describe("absorbFileSecretsIntoKeyring", () => {
|
|||
return filePath;
|
||||
}
|
||||
|
||||
it("does not throw when another process holds the lock (#2082)", async () => {
|
||||
// `withSecretFileLock` throws on a lock held past its retry budget, which
|
||||
// is right for a `set` — the user is waiting on that value — and wrong
|
||||
// here. This function is awaited directly by both `resolveSecretStore`
|
||||
// branches, so an escaping error fails store resolution and with it the
|
||||
// whole session: a stuck writer elsewhere on the box would stop the
|
||||
// Inspector from starting. Leaving the file for the next run is the only
|
||||
// acceptable outcome.
|
||||
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
|
||||
const properLockfile = (await import("proper-lockfile")).default;
|
||||
const release = await properLockfile.lock(filePath, {
|
||||
realpath: false,
|
||||
stale: 10_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore()),
|
||||
).resolves.toBeUndefined();
|
||||
await release();
|
||||
|
||||
// Left exactly as it was, and said so — asserting the *lock* message
|
||||
// specifically, since the pre-existing claim-failure warning also ends in
|
||||
// "left in place" and would let this pass without the lock ever being hit.
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Could not lock the secrets file"),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("takes no lock when there is nothing to migrate (#2082)", async () => {
|
||||
// The overwhelmingly common startup: a keychain is available and no file
|
||||
// was ever written. Locking first would create and remove a lock
|
||||
|
|
@ -702,6 +735,25 @@ describe("absorbFileSecretsIntoKeyring", () => {
|
|||
expect(existsSync(`${filePath}.lock`)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat an unlistable directory as an empty one (#2082)", async () => {
|
||||
// Only "there is no directory" proves the fresh-install case. A path
|
||||
// whose parent is a *file* answers `readdir` with ENOTDIR — the other
|
||||
// shape of "nothing there" — and must be treated the same, whereas an
|
||||
// EACCES directory that denies listing while still permitting access to
|
||||
// the known `secrets.json` must not be, or the keychain is selected and
|
||||
// those secrets go invisible with nothing said.
|
||||
await fs.writeFile(path.join(tmpDir, "not-a-dir"), "", "utf-8");
|
||||
const filePath = path.join(tmpDir, "not-a-dir", "secrets.json");
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
|
||||
await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore());
|
||||
|
||||
// ENOTDIR is a fresh-install shape: nothing said, nothing locked.
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still adopts an orphan when the live file is absent (#2082)", async () => {
|
||||
// The fast path above must not be a `stat` of `secrets.json`: an
|
||||
// interrupted migration leaves *only* the snapshot, which is precisely
|
||||
|
|
|
|||
Loading…
Reference in a new issue