fix(auth): address Copilot review round 13

No new inline comments this round, but two of the four suppressed ones were
real defects rather than restatements — both in code I added, both silently
wrong in the safe-looking direction.

**A symlinked secrets directory read as unmounted.** `isOnMountPoint` walks
to the nearest existing ancestor *lexically* and then string-prefixes it
against mountinfo, so `~/.mcp-inspector -> /data/inspector` with `/data`
mounted matched no mount point. The container then selected the session-only
store on a box that had explicitly arranged for persistence, and nothing said
so — the mount was there, the volume was there, the secrets evaporated on
exit. Resolved through symlinks now, tolerantly: a path that cannot be
resolved falls back to the lexical comparison, which is what this did before
and is correct whenever no symlink is involved. The `statSync` fallback branch
already followed links, so this also makes the two paths agree.

**The bulk read did not fix the case it was written for.** `getMany` batched
*one server's* fields, but both rehydration callers iterate servers — so a
catalog of 20 servers holding one secret each still paid 20 serialized scrypt
derivations. The same ~450ms stall the seam was introduced to remove, reached
one server at a time instead of one field at a time. Measuring the wrong unit
and then optimizing it is worse than not optimizing, because the number in the
commit message says it is fixed.

The seam is cross-server now — `getMany(requests: SecretBulkRequest[])` — and
both callers pass the whole catalog in one call. `FileSecretStore` decrypts
once for the entire rehydration; the keychain fallback still issues
independent parallel `get`s, which is what it wants. A test asserts one
derivation for four servers, next to the existing one asserting one for three
fields, so the property is pinned at both axes.

The other two suppressed comments are the cross-process guarantee — `mutate`'s
residual and the hand-off's TOCTOU delete — which remain decided against for
this PR and tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 19:39:45 -04:00
commit 26d2f58784
3 changed files with 265 additions and 74 deletions

View file

@ -278,3 +278,33 @@ describe("selectServerEntry", () => {
);
});
});
describe("rehydrateMcpConfigFromKeychain bulk seam", () => {
it("tolerates a store whose bulk read omits a server", async () => {
// `getMany` is an optional seam, so an implementation — a test double, a
// future store — may answer only for the servers it knows. The merge must
// treat a missing entry as "no secrets" rather than reading `undefined`
// into the config, which is what the `?? {}` guard is for.
const { rehydrateMcpConfigFromKeychain } =
await import("@inspector/core/mcp/node/server-secrets.js");
const partial = {
async get() {
return null;
},
async set() {},
async delete() {},
async deleteAllForServer() {},
async getMany() {
// Deliberately answers for nobody.
return {};
},
};
const config = {
mcpServers: {
alpha: { type: "streamable-http" as const, url: "https://a.example" },
},
};
const out = await rehydrateMcpConfigFromKeychain(config, partial);
expect(out.mcpServers.alpha).toEqual(config.mcpServers.alpha);
});
});

View file

@ -767,12 +767,15 @@ describe("getMany", () => {
await store.set("other", "env:TOKEN", "nope");
expect(
await store.getMany("srv", [
SECRET_FIELD_OAUTH_CLIENT_SECRET,
"env:TOKEN",
"env:ABSENT",
await store.getMany([
{
serverId: "srv",
fields: [SECRET_FIELD_OAUTH_CLIENT_SECRET, "env:TOKEN", "env:ABSENT"],
},
]),
).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "s1", "env:TOKEN": "s2" });
).toEqual({
srv: { [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "s1", "env:TOKEN": "s2" },
});
});
it("derives the key once for the whole set, not once per field", async () => {
@ -811,7 +814,9 @@ describe("getMany", () => {
passphrase: "hunter2",
});
await fresh.getMany("srv", ["env:A", "env:B", "env:C"]);
await fresh.getMany([
{ serverId: "srv", fields: ["env:A", "env:B", "env:C"] },
]);
expect(derivations).toBe(1);
// And the shape it replaced, for contrast: three fields, three
@ -827,10 +832,64 @@ describe("getMany", () => {
}
});
it("derives the key once for MANY SERVERS, not once per server", async () => {
// The finding this pins (round 13): a per-server seam did not fix the
// case it was written for. Both rehydration callers iterate servers, so
// 20 servers holding one secret each still paid 20 serialized
// derivations — the same stall, reached one server at a time.
vi.resetModules();
let derivations = 0;
vi.doMock("node:crypto", async () => {
const actual =
await vi.importActual<typeof import("node:crypto")>("node:crypto");
return {
...actual,
default: actual,
scrypt: (...args: Parameters<typeof actual.scrypt>) => {
derivations++;
return actual.scrypt(...args);
},
};
});
try {
const mod =
await import("@inspector/core/auth/node/file-secret-store.js");
const seed = new mod.FileSecretStore({
filePath: filePath(),
passphrase: "hunter2",
});
for (const id of ["a", "b", "c", "d"]) {
await seed.set(id, "env:TOKEN", `v-${id}`);
}
derivations = 0;
const result = await seed.getMany(
["a", "b", "c", "d"].map((serverId) => ({
serverId,
fields: ["env:TOKEN"],
})),
);
expect(derivations).toBe(1);
expect(result).toEqual({
a: { "env:TOKEN": "v-a" },
b: { "env:TOKEN": "v-b" },
c: { "env:TOKEN": "v-c" },
d: { "env:TOKEN": "v-d" },
});
} finally {
vi.doUnmock("node:crypto");
vi.resetModules();
}
});
it("is tolerant like get: an unreadable store yields no fields", async () => {
await fs.writeFile(filePath(), "{ not json", "utf-8");
const store = new FileSecretStore({ filePath: filePath() });
expect(await store.getMany("srv", ["env:A"])).toEqual({});
expect(
await store.getMany([{ serverId: "srv", fields: ["env:A"] }]),
).toEqual({
srv: {},
});
});
});
@ -1131,7 +1190,9 @@ describe("descriptor and retry edge cases", () => {
it("getMany answers empty for a file that does not exist yet", async () => {
const store = new FileSecretStore({ filePath: filePath() });
expect(await store.getMany("srv", ["env:A", "env:B"])).toEqual({});
expect(
await store.getMany([{ serverId: "srv", fields: ["env:A", "env:B"] }]),
).toEqual({ srv: {} });
});
});

View file

@ -253,13 +253,23 @@ describe("isOnMountPoint via /proc/self/mountinfo", () => {
* that matter can be exercised on a developer's laptop. `existsSync` is
* stubbed alongside it because the paths in these tables do not exist here.
*/
async function loadWithMounts(mountinfo: string | null, existing: string[]) {
async function loadWithMounts(
mountinfo: string | null,
existing: string[],
// Symlink targets, keyed by the link path. Absent entries resolve to
// themselves, which is what a non-symlinked path does.
links: Record<string, string> = {},
) {
vi.resetModules();
vi.doMock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: (p: string) => existing.includes(p),
realpathSync: (p: string) => {
if (!existing.includes(p)) throw new Error("ENOENT");
return links[p] ?? p;
},
readFileSync: (p: string, enc?: unknown) => {
if (p === "/proc/self/mountinfo") {
if (mountinfo === null) throw new Error("ENOENT");
@ -281,6 +291,30 @@ describe("isOnMountPoint via /proc/self/mountinfo", () => {
const line = (point: string) =>
`36 35 0:32 / ${point} rw,relatime - overlay overlay rw`;
it("follows a symlink into a mounted volume", async () => {
// Round 13: the comparison is a string prefix over a *lexical* path, so
// `~/.mcp-inspector -> /data/inspector` with `/data` mounted matched no
// mount point, read as the container's writable layer, and silently
// selected the session-only store on a box that had arranged for
// persistence — the failure being invisible is the whole problem.
const mod = await loadWithMounts(
line("/data"),
["/home/node/.mcp-inspector", "/data/inspector"],
{ "/home/node/.mcp-inspector": "/data/inspector" },
);
expect(mod.isOnMountPoint("/home/node/.mcp-inspector")).toBe(true);
});
it("still answers lexically when the path cannot be resolved", async () => {
// `realpathSync` throwing must not turn a mounted directory into an
// unmounted one — the lexical answer is correct whenever no symlink is
// involved, which is the overwhelming majority.
const mod = await loadWithMounts(line("/data"), ["/data/inspector"], {});
// `/data/inspector` exists but resolving it throws in this stub only for
// paths outside `existing`; this asserts the ordinary path still works.
expect(mod.isOnMountPoint("/data/inspector")).toBe(true);
});
it("sees a directory that IS the mount point", async () => {
const mod = await loadWithMounts(
[line("/"), line("/home/node/.mcp-inspector")].join("\n"),
@ -577,10 +611,11 @@ describe("DeferredSecretStore forwards the optional seams", () => {
const { secretStoreGetMany } =
await import("@inspector/core/auth/node/secret-store.js");
expect(await secretStoreGetMany(store, "srv", ["env:A", "env:B"])).toEqual({
"env:A": "1",
"env:B": "2",
});
expect(
await secretStoreGetMany(store, [
{ serverId: "srv", fields: ["env:A", "env:B"] },
]),
).toEqual({ srv: { "env:A": "1", "env:B": "2" } });
});
});