fix(auth): address Copilot review round 17

Three findings. The first is the fourth consecutive one in the refresh path,
so it is answered by making that path testable rather than by patching it
again.

**The refresh-after-persist wiring was covered by nothing.** It lived as two
inline `await …; refresh()` pairs in `App.tsx`, which is deliberately outside
the coverage `include` — so the hook's tests and the modals' tests could both
pass while the callback joining them was broken, leaving the security footer
stale after exactly the write that changed it. Three of the last four rounds
found defects in this path; none of them could have been caught by a test,
because there was nowhere for one to live.

Extracted to `utils/refreshingPersist`, which is inside the gate and has its
own tests: refresh runs after the persist resolves (not before — refreshing
first re-reads the descriptor the pending write is about to invalidate),
arguments pass through unchanged (the server-settings caller is
`(id, settings)`, and a transparent wrapper is the difference between saving
the entry and dropping it), and a failed persist refreshes nothing while
propagating the error.

**A tmpfs-style false promise, in the path fallback.** With neither `HOME`
nor `USERPROFILE` set — an ordinary service environment —
`defaultSecretFilePath` returned `.mcp-inspector/secrets.json`, a *relative*
path, while `FileSecretStoreOptions.filePath` and the `SecretStorageInfo.path`
the footer offers to copy both promise an absolute one. Resolved like the two
override branches above it.

**A warning that was usually false.** The session-store notice fired on every
`/api/servers` read whenever the store was in-memory — including for the
default empty catalog, where "plaintext values are left on disk" is not true
of anything, repeated on every list refresh. It is now emitted lazily, at most
once per server instance, and only when the loop actually preserves a
non-empty secrets set. A log line that is usually untrue is one people learn
to skip, which costs it the occasion it matters.

The remaining suppressed comment is the hand-off's check-to-delete race,
tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 20:51:23 -04:00
commit 78a65f7c20
2 changed files with 101 additions and 20 deletions

View file

@ -582,6 +582,25 @@ describe("defaultSecretFilePath", () => {
expect(path.isAbsolute(mod.defaultSecretFilePath())).toBe(true);
});
it("is absolute even when HOME and USERPROFILE are both unset", async () => {
// A service started with a minimal environment has neither, and the
// `"."` fallback then yields a *relative* path — while
// `FileSecretStoreOptions.filePath` and the `SecretStorageInfo.path` the
// footer offers to copy both promise an absolute one. A copied location
// that means nothing without knowing the process's cwd is not a location.
const savedHome = process.env.HOME;
const savedProfile = process.env.USERPROFILE;
delete process.env.HOME;
delete process.env.USERPROFILE;
try {
const mod = await loadWithProbe(true);
expect(path.isAbsolute(mod.defaultSecretFilePath())).toBe(true);
} finally {
if (savedHome !== undefined) process.env.HOME = savedHome;
if (savedProfile !== undefined) process.env.USERPROFILE = savedProfile;
}
});
it("defaults to ~/.mcp-inspector/secrets.json", async () => {
const mod = await loadWithProbe(true);
expect(mod.defaultSecretFilePath()).toContain(

View file

@ -4,7 +4,7 @@
* exercises the routes via real HTTP.
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import {
mkdtempSync,
readFileSync,
@ -2674,6 +2674,52 @@ describe("plaintext migration against a session-scoped store (#1950)", () => {
expect(readFileSync(configPath, "utf-8")).toBe(before);
});
it("warns only when something is actually preserved, and only once", async () => {
// The warning used to fire on every `/api/servers` read whenever the
// store was session-scoped — including for the default empty catalog,
// where "plaintext values are left on disk" is simply false, repeated on
// every list refresh. A log line that is usually untrue is one people
// learn to skip, which costs it the occasion it matters.
const warn = vi.fn();
const logger = {
warn,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as Parameters<typeof createRemoteApp>[0]["logger"];
const sessionMessage = /kept in memory for this session/;
// A catalog with no plaintext secrets: nothing is being preserved.
const emptyPath = join(tempDir, "empty.json");
writeFileSync(emptyPath, JSON.stringify({ mcpServers: {} }));
const empty = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: emptyPath,
initialConfig: { defaultEnvironment: {} },
secretStore: new SessionSecretStore(),
logger,
});
await empty.app.request(new Request("http://test/api/servers"));
await empty.app.request(new Request("http://test/api/servers"));
expect(warn.mock.calls.flat().join(" ").match(sessionMessage)).toBeNull();
// And with a plaintext secret: said once, not once per read.
warn.mockClear();
const withSecret = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: configPath,
initialConfig: { defaultEnvironment: {} },
secretStore: new SessionSecretStore(),
logger,
});
await withSecret.app.request(new Request("http://test/api/servers"));
await withSecret.app.request(new Request("http://test/api/servers"));
const said = warn.mock.calls.filter((c: unknown[]) =>
sessionMessage.test(c.map(String).join(" ")),
);
expect(said).toHaveLength(1);
});
it("aborts the migration when the keychain read fails, preserving mcp.json", async () => {
// The keychain-wins lookup used the tolerant `get`, so an unreadable
// keychain read as "nothing there" — and the write that followed would