fix(auth): address Copilot review round 3 on the secret-store PR
Nine findings (six inline, three suppressed), all taken. Two were serious. **The memory fallback made the migrations destructive.** Both migrations — mcp.json in the remote server, client.json in `node-persistence` — lift a plaintext secret off disk, write it to the secret store, and then delete it from the file. That trade is safe only while the store is at least as durable as the disk, which held by construction while `InMemorySecretStore` was a test double. Making it a production fallback broke it: on an unmounted container a plain `GET /api/servers` would have moved a user's existing secrets into RAM and lost them at exit — destroying data on a read. `SecretStore` gains an optional `isDurable()`, absent meaning durable, and both migrations withhold the disk delete when it answers false (the value is still loaded, so the session behaves normally). The production fallback is now a named `SessionSecretStore` rather than the bare `InMemorySecretStore`, so the suite's many doubles keep standing in for a *working keychain* — which is what they are there to be — and only code that deliberately chooses RAM in production says so. **Round 2's per-call descriptor never reached anyone.** Both web servers resolved it once at startup and baked it into `initialConfig`, which `/api/config` serves unchanged — so the freshness fix was inert for its only consumer. The route now takes a `secretStorageResolver` and calls it per request. The lock needed three more fixes, and they compound: - **Release removed whatever directory was at the path.** A holder whose work outran the stale threshold would delete the *next* owner's lock, letting a third writer into a section two processes believed they held. Ownership is now stamped with a random token and release only removes a lock still carrying it. - **The timeout fell through to an unlocked write** — under contention, the one moment another writer is provably active. It now throws `SecretFileLockTimeoutError`, which `set` is allowed to do. - **The first write of every process bypassed the lock**, because the secrets directory does not exist yet and `mkdir` failed ENOENT. The parent is created first. First writes are exactly when two processes are most likely to start together. Fixing the timeout exposed a fourth: with stale (10s) below timeout, a waiter stole from a holder that was merely *slow* rather than dead — reinstating the lost update. A holder now heartbeats its lock, so "stale" means the holder is gone, and the timeout is what a live-but-slow holder produces. Lock timings are injectable so the tests can exercise contention, staleness, and timeout without spending the real intervals. Also: the loose-permission state is carried into `SecretStorageInfo` as `looseMode` and rendered as the caveat (it outranks the encryption caveat — a file others can read is a live exposure, while "unencrypted" is a property of a file only its owner can open), rather than living in a startup log the browser cannot see; and `usableSecretStorage` validates `pendingEncryption` and `looseMode` as the optional booleans/numbers they are (`pendingEncryption: "false"` is truthy and would have printed advice that is the opposite of the truth) and rejects file-only fields on a keychain or memory descriptor. Tests: both migrations against a session store with a durable control alongside, so the guard is visibly the difference; `/api/config` serving a changing resolver and falling back without one; lock ownership after a steal, the timeout under a live holder, no-steal from a slow holder, and a locked first write; `looseMode` through the descriptor, the caveat ordering, and the tone; and five more rejected payload shapes. Full `npm run ci` green — 5,704 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
de7795ca54
commit
b143dd6cbd
2 changed files with 674 additions and 36 deletions
|
|
@ -15,6 +15,7 @@ import * as path from "node:path";
|
|||
import {
|
||||
acquireLock,
|
||||
FileSecretStore,
|
||||
SecretFileLockTimeoutError,
|
||||
SecretFileKeyMismatchError,
|
||||
tightenSecretFilePermissions,
|
||||
} from "@inspector/core/auth/node/file-secret-store.js";
|
||||
|
|
@ -676,16 +677,102 @@ describe("acquireLock", () => {
|
|||
await release();
|
||||
});
|
||||
|
||||
it("proceeds unlocked rather than failing when the lock cannot be created", async () => {
|
||||
// A path whose parent does not exist: mkdir fails with ENOENT, not
|
||||
// EEXIST. Refusing to write here would deny a real user a real secret to
|
||||
// prevent a race with a second process that may not exist.
|
||||
const release = await acquireLock(
|
||||
path.join(tmpDir, "no-such-dir", "secrets.json"),
|
||||
);
|
||||
it("creates the parent directory so a first write is locked too", async () => {
|
||||
// The first run has no secrets directory yet — `writeStoreFile` creates
|
||||
// it later. Without creating it here, mkdir failed ENOENT and the lock
|
||||
// was skipped, so two processes' *first* writes could both proceed
|
||||
// unlocked and overwrite each other. First writes are exactly when two
|
||||
// processes are most likely to start together.
|
||||
const target = path.join(tmpDir, "no-such-dir", "secrets.json");
|
||||
const release = await acquireLock(target);
|
||||
// A real lock was taken: a second acquisition must not be handed out.
|
||||
let secondTaken = false;
|
||||
void acquireLock(target).then((r) => {
|
||||
secondTaken = true;
|
||||
return r();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(secondTaken).toBe(false);
|
||||
await release();
|
||||
});
|
||||
|
||||
it("releases only a lock it still owns", async () => {
|
||||
// A holder whose work outran the stale threshold must not delete the
|
||||
// next owner's lock on its way out — that would drop a third writer into
|
||||
// a section two processes believed they held.
|
||||
const target = path.join(tmpDir, "owned.json");
|
||||
const lockDir = `${target}.lock`;
|
||||
const release = await acquireLock(target);
|
||||
// Simulate a steal: someone judged us stale, removed our lock, and took
|
||||
// their own.
|
||||
await fs.rm(lockDir, { recursive: true, force: true });
|
||||
await fs.mkdir(lockDir);
|
||||
await fs.writeFile(path.join(lockDir, "owner"), "someone-else", "utf-8");
|
||||
await release();
|
||||
// Their lock is untouched.
|
||||
expect(await fs.readFile(path.join(lockDir, "owner"), "utf-8")).toBe(
|
||||
"someone-else",
|
||||
);
|
||||
await fs.rm(lockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fails the mutation rather than writing past a live holder", async () => {
|
||||
// Falling through to an unlocked write under contention would be the
|
||||
// worst possible moment for it: another writer is provably active. `set`
|
||||
// is allowed to hard-fail, so this surfaces as the documented 503.
|
||||
//
|
||||
// The holder heartbeats faster than it goes stale, so the waiter can
|
||||
// never mistake it for dead and must time out instead.
|
||||
const target = path.join(tmpDir, "contended.json");
|
||||
const timings = {
|
||||
staleMs: 400,
|
||||
timeoutMs: 250,
|
||||
heartbeatMs: 50,
|
||||
pollMs: 10,
|
||||
};
|
||||
const release = await acquireLock(target, timings);
|
||||
try {
|
||||
await expect(acquireLock(target, timings)).rejects.toBeInstanceOf(
|
||||
SecretFileLockTimeoutError,
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not steal from a holder that is merely slow", async () => {
|
||||
// The heartbeat is what separates "dead" from "slow". Without it a
|
||||
// holder on a slow filesystem loses its lock mid-section and the lost
|
||||
// update comes straight back — so the waiter here must time out rather
|
||||
// than acquire, even though it waits well past the stale threshold.
|
||||
const target = path.join(tmpDir, "slow.json");
|
||||
const release = await acquireLock(target, {
|
||||
staleMs: 120,
|
||||
timeoutMs: 60_000,
|
||||
heartbeatMs: 20,
|
||||
pollMs: 10,
|
||||
});
|
||||
try {
|
||||
let stolen = false;
|
||||
void acquireLock(target, {
|
||||
staleMs: 120,
|
||||
timeoutMs: 400,
|
||||
pollMs: 10,
|
||||
})
|
||||
.then((r) => {
|
||||
stolen = true;
|
||||
return r();
|
||||
})
|
||||
.catch(() => {
|
||||
// Timed out, which is the expected outcome.
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
expect(stolen).toBe(false);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes concurrent writers through two independent store instances", async () => {
|
||||
// Two `FileSecretStore` objects have separate in-process queues, so this
|
||||
// is the cross-process race in miniature: without the file lock the second
|
||||
|
|
|
|||
|
|
@ -333,6 +333,22 @@ describe("warnAboutSecretStorage", () => {
|
|||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces a fallback with no cause to name", () => {
|
||||
// `detail` is optional — an explicitly configured store has no keychain
|
||||
// error behind it, and the banner must not print an empty error line.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage({
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
path: "/x/secrets.json",
|
||||
plaintext: false,
|
||||
});
|
||||
const printed = warn.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(printed).toContain("fell back from the OS keychain");
|
||||
expect(printed).not.toContain("Keychain error:");
|
||||
});
|
||||
|
||||
it("announces a fallback, names the cause, and points at the override", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage({
|
||||
|
|
@ -503,6 +519,59 @@ describe("defaultSecretFilePath", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("the descriptor reports a mode it could not tighten", () => {
|
||||
it("carries looseMode into the descriptor and warns once at selection", async () => {
|
||||
// The warning alone was not enough: the caveat under the secret field
|
||||
// states the mode as fact, and on this box that statement was untrue
|
||||
// while the browser had no way to know.
|
||||
const filePath = path.join(tmpDir, "secrets.json");
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ version: 1, encryption: "none", secrets: {} }),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.chmod(filePath, 0o644);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs/promises", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
chmod: vi.fn(() =>
|
||||
Promise.reject(new Error("EPERM: operation not permitted")),
|
||||
),
|
||||
};
|
||||
});
|
||||
vi.doMock("@inspector/core/auth/node/secret-store.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@inspector/core/auth/node/secret-store.js")
|
||||
>("@inspector/core/auth/node/secret-store.js");
|
||||
return {
|
||||
...actual,
|
||||
probeKeyringAvailable: vi.fn(async () => ({ available: true })),
|
||||
};
|
||||
});
|
||||
try {
|
||||
const mod =
|
||||
await import("@inspector/core/auth/node/secret-store-selection.js");
|
||||
const info = await mod.getSecretStorageInfo();
|
||||
expect(info.looseMode).toBe(0o644);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("0644"));
|
||||
} finally {
|
||||
vi.doUnmock("node:fs/promises");
|
||||
vi.doUnmock("@inspector/core/auth/node/secret-store.js");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the descriptor reports the file, not the write policy", () => {
|
||||
it("says plaintext while a pre-existing plaintext file is only *going* to be encrypted", async () => {
|
||||
// The transitional state: the passphrase is set, so the next write will
|
||||
|
|
|
|||
Loading…
Reference in a new issue