Skip to content

Commit 8941935

Browse files
cliffhallclaude
andcommitted
fix(servers): tolerate keychain unavailability on non-set ops (#1356)
CI on Linux failed because `@napi-rs/keyring` hard-errors without libsecret, and the route handlers were touching the keychain even for no-secret flows (defensive `deleteAllForServer` on POST/DELETE, sweep on PUT). The user's intent for the Linux fallback was "hard fail when a secret is actually involved" — not on every keychain touch. Make `KeyringSecretStore.get / delete / deleteAllForServer` silently degrade when the keychain is unavailable (return null / no-op); `set` remains the one operation that hard-fails with `KeychainUnavailableError`. The migration path catches the error and preserves the disk plaintext rather than losing the secret. Also inject `InMemorySecretStore` in `useServers.test.tsx` and `servers-events.test.ts` so tests never touch a developer's real keychain even when libsecret is present. Adds `KeyringSecretStore` tests (vi.mock'd native bindings) and an integration suite that simulates Linux-without-libsecret to verify the tolerance contract end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b04e51 commit 8941935

6 files changed

Lines changed: 453 additions & 27 deletions

File tree

clients/web/src/test/core/react/useServers.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { join } from "node:path";
1313
import { useServers } from "@inspector/core/react/useServers";
1414
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server";
1515
import { DEFAULT_SEED_CONFIG } from "@inspector/core/mcp/serverList";
16+
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store";
1617
import type { MCPConfig } from "@inspector/core/mcp/types";
1718

1819
interface Harness {
@@ -29,6 +30,10 @@ function setupHarness(): Harness {
2930
dangerouslyOmitAuth: true,
3031
mcpConfigPath: configPath,
3132
initialConfig: { defaultEnvironment: {} },
33+
// Inject an in-memory secret store so the test never touches the
34+
// developer's real OS keychain (and so the suite passes on Linux CI
35+
// runners without libsecret).
36+
secretStore: new InMemorySecretStore(),
3237
});
3338
// Wrap so the typing matches Web fetch — Hono's app.fetch expects a Request
3439
// (not a URL string), so build one from string/URL inputs before dispatch.

clients/web/src/test/integration/auth/node/secret-store.test.ts

Lines changed: 211 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,78 @@
11
/**
22
* Contract tests for the secret-store abstraction.
33
*
4-
* The keyring-backed default implementation needs platform native bindings
5-
* (libsecret on Linux) that aren't reliably present in CI, so the suite
6-
* exercises the `InMemorySecretStore` here — the same interface the
7-
* production code is tested against via the `/api/servers` integration
8-
* suite (which injects this in-memory impl). Coverage of the keyring
9-
* adapter itself is exercised by hand on macOS/Windows during development.
4+
* `InMemorySecretStore` is exercised directly. `KeyringSecretStore` is
5+
* exercised via a `vi.mock` of `@napi-rs/keyring` — the native bindings
6+
* aren't reliably present in CI (Linux runners ship without libsecret),
7+
* so the suite stubs the native side and asserts the tolerance contract
8+
* (`get` returns null on failure, destructive ops no-op, `set` is the
9+
* one operation that hard-fails with `KeychainUnavailableError`).
1010
*/
11-
import { describe, it, expect, beforeEach } from "vitest";
11+
import { describe, it, expect, beforeEach, vi } from "vitest";
12+
13+
// The mock must be hoisted above the `await import` of secret-store
14+
// inside the `KeyringSecretStore` describe block. Use `vi.hoisted` so
15+
// references are captured before the import is evaluated.
16+
const keyringMocks = vi.hoisted(() => {
17+
const password = new Map<string, string | null>();
18+
const reset = () => password.clear();
19+
// Behavior hooks each test can flip to simulate keychain unavailability
20+
// on specific operations. Defaults: real-ish in-memory behavior.
21+
const failures = {
22+
getThrows: false,
23+
setThrows: false,
24+
deleteThrows: false,
25+
findThrows: false,
26+
deleteThrowsNoEntry: false,
27+
};
28+
const credentials = (): Array<{ account: string; password: string }> => {
29+
const out: Array<{ account: string; password: string }> = [];
30+
for (const [k, v] of password.entries()) {
31+
if (v !== null) out.push({ account: k, password: v });
32+
}
33+
return out;
34+
};
35+
class AsyncEntry {
36+
private readonly key: string;
37+
constructor(_service: string, username: string) {
38+
this.key = username;
39+
}
40+
async getPassword(): Promise<string | undefined> {
41+
if (failures.getThrows) throw new Error("keychain get unavailable");
42+
const v = password.get(this.key);
43+
return v === undefined || v === null ? undefined : v;
44+
}
45+
async setPassword(value: string): Promise<void> {
46+
if (failures.setThrows) throw new Error("keychain set unavailable");
47+
password.set(this.key, value);
48+
}
49+
async deleteCredential(): Promise<boolean> {
50+
if (failures.deleteThrowsNoEntry) throw new Error("No entry found");
51+
if (failures.deleteThrows) throw new Error("keychain delete unavailable");
52+
return password.delete(this.key);
53+
}
54+
}
55+
const findCredentialsAsync = async (): Promise<
56+
Array<{ account: string; password: string }>
57+
> => {
58+
if (failures.findThrows) throw new Error("keychain find unavailable");
59+
return credentials();
60+
};
61+
return { AsyncEntry, findCredentialsAsync, failures, password, reset };
62+
});
63+
64+
vi.mock("@napi-rs/keyring", () => ({
65+
AsyncEntry: keyringMocks.AsyncEntry,
66+
findCredentialsAsync: keyringMocks.findCredentialsAsync,
67+
}));
68+
1269
import {
1370
InMemorySecretStore,
71+
KeyringSecretStore,
72+
KeychainUnavailableError,
1473
SECRET_FIELD_OAUTH_CLIENT_SECRET,
1574
envSecretField,
75+
parseAccount,
1676
type SecretStore,
1777
} from "@inspector/core/auth/node/secret-store.js";
1878

@@ -119,3 +179,147 @@ describe("InMemorySecretStore", () => {
119179
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("");
120180
});
121181
});
182+
183+
describe("parseAccount", () => {
184+
it("splits `${serverId}:${field}` on the first colon", () => {
185+
expect(parseAccount("srv:oauth-client-secret")).toEqual({
186+
serverId: "srv",
187+
field: "oauth-client-secret",
188+
});
189+
});
190+
191+
it("allows the field to contain colons (env:KEY uses one)", () => {
192+
expect(parseAccount("srv:env:API_KEY")).toEqual({
193+
serverId: "srv",
194+
field: "env:API_KEY",
195+
});
196+
});
197+
198+
it("returns null when no separator is present", () => {
199+
expect(parseAccount("noseparator")).toBe(null);
200+
});
201+
202+
it("returns null for a leading or trailing colon (empty side)", () => {
203+
expect(parseAccount(":field")).toBe(null);
204+
expect(parseAccount("srv:")).toBe(null);
205+
});
206+
});
207+
208+
describe("KeyringSecretStore (mocked native bindings)", () => {
209+
let store: KeyringSecretStore;
210+
211+
beforeEach(() => {
212+
keyringMocks.reset();
213+
keyringMocks.failures.getThrows = false;
214+
keyringMocks.failures.setThrows = false;
215+
keyringMocks.failures.deleteThrows = false;
216+
keyringMocks.failures.findThrows = false;
217+
keyringMocks.failures.deleteThrowsNoEntry = false;
218+
store = new KeyringSecretStore();
219+
});
220+
221+
it("round-trips a set then get", async () => {
222+
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
223+
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
224+
"shh",
225+
);
226+
});
227+
228+
it("get returns null when getPassword throws (keychain unavailable)", async () => {
229+
// get is tolerant: there's no value to surface so degrading to "null"
230+
// matches the absence semantic the caller already handles.
231+
keyringMocks.failures.getThrows = true;
232+
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
233+
null,
234+
);
235+
});
236+
237+
it("get returns null when the underlying entry is absent (no value set)", async () => {
238+
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
239+
null,
240+
);
241+
});
242+
243+
it("set throws KeychainUnavailableError when setPassword throws", async () => {
244+
// set is the one operation that hard-fails — losing data silently
245+
// is worse than surfacing a clear error the user can act on.
246+
keyringMocks.failures.setThrows = true;
247+
await expect(
248+
store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"),
249+
).rejects.toBeInstanceOf(KeychainUnavailableError);
250+
});
251+
252+
it("delete silently treats a 'no entry' error as success", async () => {
253+
keyringMocks.failures.deleteThrowsNoEntry = true;
254+
await expect(
255+
store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET),
256+
).resolves.toBeUndefined();
257+
});
258+
259+
it("delete silently no-ops when the keychain is unavailable", async () => {
260+
keyringMocks.failures.deleteThrows = true;
261+
await expect(
262+
store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET),
263+
).resolves.toBeUndefined();
264+
});
265+
266+
it("delete actually removes the value when the keychain is available", async () => {
267+
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v");
268+
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
269+
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
270+
null,
271+
);
272+
});
273+
274+
it("deleteAllForServer no-ops when findCredentialsAsync throws", async () => {
275+
// We don't even know what was written, so there's nothing to sweep.
276+
// Critically, this must not throw — the route's defensive sweep on
277+
// POST and DELETE depends on it.
278+
keyringMocks.failures.findThrows = true;
279+
await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined();
280+
});
281+
282+
it("deleteAllForServer removes every entry under the given id", async () => {
283+
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
284+
await store.set("alpha", envSecretField("K"), "b");
285+
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched");
286+
287+
await store.deleteAllForServer("alpha");
288+
289+
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
290+
null,
291+
);
292+
expect(await store.get("alpha", envSecretField("K"))).toBe(null);
293+
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
294+
"untouched",
295+
);
296+
});
297+
298+
it("deleteAllForServer ignores entries on a different id that share a prefix", async () => {
299+
// The `parseAccount` check guards against a literal startsWith match
300+
// wrongly sweeping `alpha-prime:...` when deleting `alpha`.
301+
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
302+
await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p");
303+
304+
await store.deleteAllForServer("alpha");
305+
306+
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
307+
null,
308+
);
309+
expect(
310+
await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET),
311+
).toBe("p");
312+
});
313+
314+
it("KeychainUnavailableError carries the underlying error message", async () => {
315+
keyringMocks.failures.setThrows = true;
316+
try {
317+
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v");
318+
throw new Error("expected throw");
319+
} catch (err) {
320+
expect(err).toBeInstanceOf(KeychainUnavailableError);
321+
expect((err as Error).message).toMatch(/keychain set unavailable/);
322+
expect((err as Error).message).toMatch(/libsecret/);
323+
}
324+
});
325+
});

clients/web/src/test/integration/mcp/remote/servers-events.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { join } from "node:path";
2727
import { serve } from "@hono/node-server";
2828
import type { ServerType } from "@hono/node-server";
2929
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
30+
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js";
3031

3132
interface Harness {
3233
baseUrl: string;
@@ -43,6 +44,10 @@ async function setup(): Promise<Harness> {
4344
dangerouslyOmitAuth: true,
4445
mcpConfigPath: configPath,
4546
initialConfig: { defaultEnvironment: {} },
47+
// Inject an in-memory secret store so the suite passes on Linux CI
48+
// runners without libsecret (and so a developer running locally
49+
// doesn't pollute their real OS keychain with test entries).
50+
secretStore: new InMemorySecretStore(),
4651
});
4752
const { baseUrl, server } = await new Promise<{
4853
baseUrl: string;

0 commit comments

Comments
 (0)