feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
Closes #1950 Before this, a box with no reachable OS keychain — the published container (no D-Bus session, #1848), Android/Termux (no platform binary, #1905), a minimal Linux install without libsecret — could not persist an OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore` degraded into a store that silently holds nothing: `get` returned null, `delete` no-opped, and `set` hard-failed with a 503. That is the correct failure behavior, but it left the documented, supported way to run the Inspector with no secret persistence whatsoever. Adds the two missing implementations and, more importantly, the policy that picks one and the surfaces that say which was picked. - `FileSecretStore` — one `0600` JSON document at `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The whole map is encrypted as a unit rather than value-by-value so the account names — `${serverId}:${field}` — are hidden too; a per-value scheme would leave a readable index of which servers you hold a client secret for. A file that can no longer be decrypted reads as empty (matching the interface's read tolerance) but *refuses to be written*, because replacing a file of still-valid secrets to satisfy an additive request destroys data. - `secret-store-selection.ts` — the policy. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright; otherwise probe the keychain and, if unreachable, fall back loudly. The fallback is `memory` in a container whose secrets directory is not on a mount, and `file` otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `--rm` will discard. Cached per process so the banner, `/api/config`, and the store doing the writing cannot disagree. - Automatic, but never silent. The downgrade prints at startup, rides `GET /api/config`, and is named in a permanent 32px footer at the bottom of both dialogs that accept a secret. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is the thing a user dismisses before doing the work it describes. The plaintext and memory cases render their caveat inline in a warning tone — a warning you must hover to discover is not one. - `SecretStoreUnavailableError` is now the base every store's `set` throws, so the routes translate one condition into a 503 instead of matching `KeychainUnavailableError` by name. Docs: the container section of the root README gains a table of which store you get and how to make secrets survive, plus the `MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules. Tests: the existing interface contract block is extracted to `secretStoreContract.ts` and run against all three implementations, so none can drift on the availability contract. `FileSecretStore` is tested against a real temp filesystem — mode, cross-instance round-trip, that the ciphertext does not contain the plaintext, the read-modify-write race, and every refusal path. Full `npm run ci` passes, per-file gate included. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
43525d9ea2
commit
bc408f5869
31 changed files with 2827 additions and 146 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -51,10 +51,30 @@ v2/main/
|
|||
│ │ # wrapper that rewrites the discovered AS
|
||||
│ │ # metadata document, the one seam SDK v2 routes
|
||||
│ │ # BOTH endpoints through (neither reaches the
|
||||
│ │ # OAuthClientProvider) — #1906)
|
||||
│ │ # OAuthClientProvider) — #1906;
|
||||
│ │ # secret-storage-info.ts browser-safe
|
||||
│ │ # descriptor of WHERE a typed secret lands
|
||||
│ │ # — kind/plaintext/durable plus the label,
|
||||
│ │ # tone, caveat and summary helpers shared
|
||||
│ │ # by the startup banner and the web UI, so
|
||||
│ │ # terminal and browser cannot describe the
|
||||
│ │ # same store differently — #1950)
|
||||
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
||||
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
|
||||
│ │ │ # runner-interactive-oauth loopback callback flow)
|
||||
│ │ │ # runner-interactive-oauth loopback callback flow);
|
||||
│ │ │ # plus the SecretStore backends and their selection —
|
||||
│ │ │ # secret-store.ts (KeyringSecretStore + InMemorySecretStore,
|
||||
│ │ │ # the SecretStoreUnavailableError base every store's `set`
|
||||
│ │ │ # throws and the routes turn into a 503, and the keychain
|
||||
│ │ │ # probe), file-secret-store.ts (0600 JSON, AES-256-GCM when
|
||||
│ │ │ # MCP_INSPECTOR_SECRET_KEY is set — refuses to overwrite a
|
||||
│ │ │ # file it cannot decrypt rather than destroying it), and
|
||||
│ │ │ # secret-store-selection.ts (the POLICY: explicit
|
||||
│ │ │ # MCP_INSPECTOR_SECRET_STORE wins, else probe the keychain,
|
||||
│ │ │ # else fall back LOUDLY — to memory in a container with
|
||||
│ │ │ # nothing mounted, to file otherwise; cached per process so
|
||||
│ │ │ # the banner, /api/config and the store doing the writing
|
||||
│ │ │ # cannot disagree — #1950)
|
||||
│ │ └── remote/ # Remote OAuth storage (delegates to the remote server)
|
||||
│ ├── client/ # Install-level client config (`client.json`): browser-safe
|
||||
│ │ # parse/validate (config-parse.ts) + Node load/save
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -30,7 +30,9 @@ inspector/
|
|||
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
||||
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
|
||||
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
|
||||
│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends)
|
||||
│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends);
|
||||
│ │ # plus per-server secret storage — the keychain/file/memory SecretStore
|
||||
│ │ # implementations, the selection policy, and the descriptor the banner and UI report
|
||||
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
|
||||
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
|
||||
│ │ # schema collapse shared by the web and TUI form builders
|
||||
|
|
@ -455,6 +457,29 @@ docker run --rm -p 127.0.0.1:6274:6274 \
|
|||
|
||||
The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`.
|
||||
|
||||
**Where secrets go, and how to make them survive (#1950).** The Inspector keeps the values it deliberately does *not* write to `mcp.json` — an OAuth client secret, an enterprise IdP client secret, each stdio `env:` value — in the **OS keychain**. A container has no keychain (the published image has no D-Bus session), so on startup the Inspector probes for one and falls back, saying so in the logs and in a permanent footer at the bottom of the Client Settings and Server Settings dialogs. Which fallback you get depends on whether the directory it would write to is going to survive:
|
||||
|
||||
| Situation | Store | Secrets survive a restart? |
|
||||
| --- | --- | --- |
|
||||
| Keychain reachable (a normal desktop install) | OS keychain | Yes |
|
||||
| Container, **no volume** on `/home/node/.mcp-inspector` | Memory | No — session only |
|
||||
| Container **with** that volume, or any host without a keychain | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes |
|
||||
|
||||
So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to.
|
||||
|
||||
**A file-backed store is unencrypted unless you give it a key.** Set `MCP_INSPECTOR_SECRET_KEY` to any passphrase and the file is encrypted with AES-256-GCM (the passphrase is stretched with scrypt against a per-file random salt). Without it the file is still `0600`, but the values are readable to anyone who can read the file — which the startup log and the settings footer both say, every session, in a warning tone:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 127.0.0.1:6274:6274 \
|
||||
-v mcp-inspector-data:/home/node/.mcp-inspector \
|
||||
-e MCP_INSPECTOR_SECRET_KEY="$MY_PASSPHRASE" \
|
||||
ghcr.io/modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
Setting the passphrase later is safe — the next write upgrades an existing plaintext file in place. **Changing or losing it is not**: a file that can no longer be decrypted is read as empty and *refuses to be written*, rather than being silently replaced with a new one holding only your latest secret. Restore the original passphrase, or delete `secrets.json` and re-enter the values.
|
||||
|
||||
Two env vars override the automatic choice: `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, and `MCP_INSPECTOR_SECRET_FILE` moves the file.
|
||||
|
||||
**Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
|
|||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
|
||||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
printServerBanner,
|
||||
|
|
@ -49,6 +50,11 @@ export async function startHonoServer(
|
|||
(config.dangerouslyOmitAuth ? "" : randomBytes(32).toString("hex"));
|
||||
|
||||
const rootPath = config.staticRoot ?? __dirname;
|
||||
// Resolve the secret store before the API is built so the descriptor the
|
||||
// browser reads (`/api/config`) and the one the banner prints come from the
|
||||
// same resolution — the selection is cached, so this is also the only place
|
||||
// the keychain probe actually runs.
|
||||
const secretStorage = await getSecretStorageInfo();
|
||||
|
||||
const { app: apiApp, close: closeApi } = createRemoteApp({
|
||||
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
|
||||
|
|
@ -60,7 +66,7 @@ export async function startHonoServer(
|
|||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config),
|
||||
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
|
|
@ -123,6 +129,7 @@ export async function startHonoServer(
|
|||
info.port,
|
||||
resolvedAuthToken,
|
||||
sandboxUrl ?? undefined,
|
||||
secretStorage,
|
||||
);
|
||||
if (config.autoOpen) {
|
||||
// Never a bare `open(url)`: this callback is synchronous, so a
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
|||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
|
||||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
printServerBanner,
|
||||
|
|
@ -66,6 +67,9 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
allowedOrigins: config.allowedOrigins,
|
||||
});
|
||||
await sandboxController.start();
|
||||
// Resolved before the API is built so `/api/config` and the banner
|
||||
// report the same store (the selection itself is cached process-wide).
|
||||
const secretStorage = await getSecretStorageInfo();
|
||||
|
||||
const {
|
||||
app: honoApp,
|
||||
|
|
@ -81,7 +85,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config),
|
||||
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
||||
});
|
||||
|
||||
// Expose the resolved token to `transformIndexHtml`. Left empty when
|
||||
|
|
@ -111,6 +115,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
actualPort,
|
||||
resolvedToken,
|
||||
sandboxUrl ?? undefined,
|
||||
secretStorage,
|
||||
);
|
||||
|
||||
if (config.autoOpen) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
LEGACY_AUTH_TOKEN_ENV,
|
||||
} from "../../../core/mcp/remote/constants.ts";
|
||||
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
|
||||
import type { SecretStorageInfo } from "../../../core/auth/secret-storage-info.ts";
|
||||
import { secretStorageSummary } from "../../../core/auth/secret-storage-info.ts";
|
||||
import { readInspectorVersionSafe } from "../../../core/node/version.ts";
|
||||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
||||
import { resolveBindHostname } from "./resolve-bind-host.js";
|
||||
|
|
@ -112,8 +114,17 @@ function defaultEnvironmentFromProcess(
|
|||
*/
|
||||
export function webServerConfigToInitialPayload(
|
||||
config: WebServerConfig,
|
||||
secretStorage?: SecretStorageInfo,
|
||||
): InitialConfigPayload {
|
||||
return { ...transportDefaults(config), version: inspectorVersion };
|
||||
return {
|
||||
...transportDefaults(config),
|
||||
version: inspectorVersion,
|
||||
// Omitted rather than defaulted when the caller didn't resolve a store:
|
||||
// the UI treats an absent descriptor as "unknown" and shows nothing,
|
||||
// which is the only honest rendering. A default of "keychain" would put a
|
||||
// confident wrong answer under the field where a secret is typed.
|
||||
...(secretStorage ? { secretStorage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The transport-specific defaults half of the `/api/config` payload. */
|
||||
|
|
@ -168,6 +179,7 @@ export function printServerBanner(
|
|||
actualPort: number,
|
||||
resolvedToken: string,
|
||||
sandboxUrl: string | undefined,
|
||||
secretStorage?: SecretStorageInfo,
|
||||
): string {
|
||||
// Advertise `localhost` for a wildcard bind (`http://0.0.0.0:PORT` is an
|
||||
// awkward URL to click and points the user at a reachable, allow-listed
|
||||
|
|
@ -197,6 +209,14 @@ export function printServerBanner(
|
|||
console.log(` Auth token: ${resolvedToken}\n`);
|
||||
}
|
||||
|
||||
// Where a secret typed into this session lands (#1950). Printed for every
|
||||
// store, not only the surprising ones: the fallback already warns
|
||||
// separately at selection time, and a line that appears only when
|
||||
// something is wrong can't be used to confirm that nothing is.
|
||||
if (secretStorage) {
|
||||
console.log(` Secrets: ${secretStorageSummary(secretStorage)}\n`);
|
||||
}
|
||||
|
||||
if (config.autoOpen) {
|
||||
console.log("Opening browser...");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@
|
|||
light surfaces; a lighter red on the dark card/header (see dark override) —
|
||||
`red-6` fell just under WCAG AA on both. */
|
||||
--inspector-danger-text: var(--mantine-color-red-7);
|
||||
/* Caution text (the secret-storage footer when secrets are unencrypted or
|
||||
session-only, #1950). Yellow is the right *meaning* — this is "know what
|
||||
you are doing", not "this failed" — but yellow is also the hardest hue to
|
||||
make readable: `yellow-6`, the tone the log-level badges use on a filled
|
||||
chip, is far under AA as body text on a light surface. `yellow-8` clears it
|
||||
here, and the dark override lifts to `yellow-4` the same way danger does. */
|
||||
--inspector-warning-text: var(--mantine-color-yellow-8);
|
||||
|
||||
/* ── Surfaces ──────────────────────────────────────────── */
|
||||
/* Light-grey app background so the white cards read as raised. Drives
|
||||
|
|
@ -204,6 +211,9 @@
|
|||
--mantine-color-dimmed: var(--mantine-color-dark-1) !important;
|
||||
/* Lighter danger red for the dark card/header (deep red would fail AA). */
|
||||
--inspector-danger-text: var(--mantine-color-red-4);
|
||||
/* Same inversion for caution text: the deep `yellow-8` is unreadable on a
|
||||
dark surface. */
|
||||
--inspector-warning-text: var(--mantine-color-yellow-4);
|
||||
}
|
||||
|
||||
/* A11y contrast (light): Mantine sets these per-scheme on a selector more
|
||||
|
|
|
|||
|
|
@ -741,6 +741,7 @@ function App() {
|
|||
sandboxUrl,
|
||||
writable: serverListWritable,
|
||||
version: inspectorVersion,
|
||||
secretStorage,
|
||||
} = useInitialConfig({
|
||||
baseUrl: configBaseUrl,
|
||||
authToken: getAuthToken(),
|
||||
|
|
@ -4618,6 +4619,7 @@ function App() {
|
|||
onClearStoredOAuth={
|
||||
settingsModalIsStdio ? undefined : handleClearStoredOAuthFromSettings
|
||||
}
|
||||
secretStorage={secretStorage}
|
||||
/>
|
||||
<ClientSettingsModal
|
||||
key={
|
||||
|
|
@ -4629,6 +4631,7 @@ function App() {
|
|||
onSettingsChange={onClientSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={logoutEmaIdp}
|
||||
secretStorage={secretStorage}
|
||||
/>
|
||||
{initializeResult && activeServer && (
|
||||
<ConnectionInfoModal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
import { SecretStorageFooter } from "./SecretStorageFooter";
|
||||
|
||||
const keyring: SecretStorageInfo = {
|
||||
kind: "keyring",
|
||||
reason: "default",
|
||||
durable: true,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SecretStorageFooter> = {
|
||||
title: "Elements/SecretStorageFooter",
|
||||
component: SecretStorageFooter,
|
||||
args: { info: keyring },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SecretStorageFooter>;
|
||||
|
||||
// The ordinary case on a desktop install: the OS keychain, stated quietly.
|
||||
export const Keychain: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: OS keychain"),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"neutral",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// A container with a mounted volume and `MCP_INSPECTOR_SECRET_KEY` set: durable,
|
||||
// encrypted, and therefore still quiet — the path is the useful part.
|
||||
export const EncryptedFile: Story = {
|
||||
args: {
|
||||
info: {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: false,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: File (encrypted)"),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText("/home/node/.mcp-inspector/secrets.json"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// The same container without a passphrase. The caveat is rendered inline and in
|
||||
// the warning tone — this is what "plaintext gets a loud banner" looks like at
|
||||
// the point where the secret is actually typed.
|
||||
export const UnencryptedFile: Story = {
|
||||
args: {
|
||||
info: {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: File (unencrypted)"),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warn",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// The published container with nothing mounted: session-scoped, and it says so
|
||||
// rather than promising a durability the writable layer can't keep.
|
||||
export const InMemory: Story = {
|
||||
args: { info: { kind: "memory", reason: "fallback", durable: false } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: Memory (this session only)"),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(/lost when the Inspector exits/),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// A backend that reports no store (legacy build): the band is absent entirely.
|
||||
export const Unknown: Story = {
|
||||
args: { info: undefined },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByTestId("secret-storage-footer")).toBeNull();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { SecretStorageFooter } from "./SecretStorageFooter";
|
||||
|
||||
const keyring: SecretStorageInfo = {
|
||||
kind: "keyring",
|
||||
reason: "default",
|
||||
durable: true,
|
||||
};
|
||||
const encryptedFile: SecretStorageInfo = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: false,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
};
|
||||
const plaintextFile: SecretStorageInfo = { ...encryptedFile, plaintext: true };
|
||||
const memory: SecretStorageInfo = {
|
||||
kind: "memory",
|
||||
reason: "fallback",
|
||||
durable: false,
|
||||
};
|
||||
|
||||
describe("SecretStorageFooter", () => {
|
||||
it("names the keychain with no caveat and no warning tone", () => {
|
||||
renderWithMantine(<SecretStorageFooter info={keyring} />);
|
||||
expect(screen.getByText("Secrets: OS keychain")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"neutral",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the path for an encrypted file, quietly", () => {
|
||||
// Nothing is wrong here, so the band must not shout — the path is the
|
||||
// useful part and the tone stays neutral.
|
||||
renderWithMantine(<SecretStorageFooter info={encryptedFile} />);
|
||||
expect(screen.getByText("Secrets: File (encrypted)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("/home/node/.mcp-inspector/secrets.json"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"neutral",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the plaintext caveat inline, not only on hover", () => {
|
||||
// This is the "loud banner" decision made concrete: a warning you have
|
||||
// to hover to discover is not a warning. The caveat takes the visible
|
||||
// slot, displacing the path (which stays in the tooltip).
|
||||
renderWithMantine(<SecretStorageFooter info={plaintextFile} />);
|
||||
expect(screen.getByText("Secrets: File (unencrypted)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Set MCP_INSPECTOR_SECRET_KEY to encrypt them/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns that an in-memory store loses secrets on exit", () => {
|
||||
renderWithMantine(<SecretStorageFooter info={memory} />);
|
||||
expect(
|
||||
screen.getByText("Secrets: Memory (this session only)"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/lost when the Inspector exits/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing when the backend didn't report a store", () => {
|
||||
// A guessed answer under a secret field is worse than no answer, so the
|
||||
// unknown case is silence rather than a default.
|
||||
renderWithMantine(<SecretStorageFooter info={undefined} />);
|
||||
expect(screen.queryByTestId("secret-storage-footer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { Group, Text, Tooltip } from "@mantine/core";
|
||||
import { TbAlertTriangle, TbDeviceDesktop, TbLock } from "react-icons/tb";
|
||||
import {
|
||||
secretStorageCaveat,
|
||||
secretStorageLabel,
|
||||
secretStorageTone,
|
||||
type SecretStorageInfo,
|
||||
} from "@inspector/core/auth/secret-storage-info.js";
|
||||
|
||||
/**
|
||||
* Height of the footer band. Matches `InspectorView`'s `FOOTER_HEIGHT`
|
||||
* deliberately: these are the app's only permanent status strips, and a
|
||||
* modal footer standing a few pixels taller than the one behind it would
|
||||
* read as a different kind of thing.
|
||||
*/
|
||||
export const SECRET_STORAGE_FOOTER_HEIGHT = 32;
|
||||
|
||||
export interface SecretStorageFooterProps {
|
||||
/**
|
||||
* The active store, from `GET /api/config`. Renders nothing when
|
||||
* undefined — a backend that didn't report one leaves the question
|
||||
* genuinely open, and an invented answer directly beneath a secret field
|
||||
* is worse than silence.
|
||||
*/
|
||||
info?: SecretStorageInfo;
|
||||
}
|
||||
|
||||
// A thin band, same height as the app footer. Mantine's compound Modal has no
|
||||
// `Modal.Footer`, so it renders as the last child of `Modal.Content`, after the
|
||||
// `Modal.Body`, and its theme variant pins it with `position: sticky` — the
|
||||
// mirror of Mantine's own sticky header, because `Modal.Content` scrolls
|
||||
// header, body and footer as one. `nowrap` keeps it to one line at any modal
|
||||
// width; the truncating detail text on the right absorbs a narrow modal.
|
||||
const FooterBand = Group.withProps({
|
||||
variant: "secretStorageFooter",
|
||||
h: SECRET_STORAGE_FOOTER_HEIGHT,
|
||||
px: "md",
|
||||
gap: 6,
|
||||
align: "center",
|
||||
wrap: "nowrap",
|
||||
w: "100%",
|
||||
});
|
||||
|
||||
const FooterLabel = Text.withProps({
|
||||
size: "xs",
|
||||
fw: 500,
|
||||
variant: "nowrap",
|
||||
});
|
||||
|
||||
// The detail — a path, or the caveat sentence — is the part allowed to lose
|
||||
// characters, since the label before it carries the answer.
|
||||
//
|
||||
// `lineClamp` rather than `truncate`, and the difference is load-bearing.
|
||||
// `Modal.Content` sizes its scroll container to its children's **max-content**
|
||||
// width, and `truncate` sets `white-space: nowrap`, whose max-content is the
|
||||
// entire sentence — so the band grew the container to 770px inside a 620px
|
||||
// modal and the *form above it* got clipped at the right edge (measured).
|
||||
// Clamped text still wraps internally, so its max-content is merely its
|
||||
// longest word, and the container stays the modal's width; the clamp then
|
||||
// keeps it to the one line this 32px band has room for. `miw: 0` + `flex: 1`
|
||||
// let it shrink to the leftover space rather than to its content.
|
||||
const FooterDetail = Text.withProps({
|
||||
size: "xs",
|
||||
lineClamp: 1,
|
||||
miw: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
|
||||
/**
|
||||
* The permanent "here is where your secrets go" strip at the bottom of the
|
||||
* Client Settings and Server Settings modals (#1950).
|
||||
*
|
||||
* Those two dialogs are the only places the Inspector accepts a secret —
|
||||
* an OAuth client secret, an enterprise IdP client secret, a stdio `env:`
|
||||
* value — so they are where the answer belongs. The alternatives were
|
||||
* considered and rejected: a startup banner is seen once, by whoever
|
||||
* started the process, possibly hours before anyone types anything; a
|
||||
* toast is seen once and then gone; a dismissible banner is, by design,
|
||||
* the thing a user makes disappear before doing the work it describes.
|
||||
* This is always present, is not dismissible, and has no empty state
|
||||
* beyond "the backend didn't say".
|
||||
*
|
||||
* When the store is lossy or unencrypted the caveat is rendered *inline*
|
||||
* rather than left to the tooltip. That is the whole point of the
|
||||
* "plaintext gets a loud warning" decision: a warning you have to hover to
|
||||
* discover is not one. The tooltip carries the full sentence for the case
|
||||
* where a narrow modal truncates it, and — for the quiet stores — the file
|
||||
* path.
|
||||
*
|
||||
* Deliberately read-only for now. Choosing the store from here is the
|
||||
* obvious follow-up, but the gap this closes is that users could not find
|
||||
* out *at all*, and closing that does not require a control.
|
||||
*/
|
||||
export function SecretStorageFooter({ info }: SecretStorageFooterProps) {
|
||||
if (!info) return null;
|
||||
|
||||
const caveat = secretStorageCaveat(info);
|
||||
const warn = secretStorageTone(info) === "warn";
|
||||
const color = warn
|
||||
? "var(--inspector-warning-text)"
|
||||
: "var(--inspector-text-secondary)";
|
||||
// The caveat wins the visible slot when there is one; otherwise the path,
|
||||
// which only the (quiet) encrypted-file case has.
|
||||
const detail = caveat ?? info.path;
|
||||
const Icon = warn
|
||||
? TbAlertTriangle
|
||||
: info.kind === "keyring"
|
||||
? TbDeviceDesktop
|
||||
: TbLock;
|
||||
|
||||
const band = (
|
||||
<FooterBand
|
||||
data-testid="secret-storage-footer"
|
||||
data-tone={warn ? "warn" : "neutral"}
|
||||
>
|
||||
{/* A react-icons glyph, not a Mantine factory component — outside the
|
||||
`.withProps()` rule, and it takes `color` as a prop rather than a
|
||||
style. */}
|
||||
<Icon size={ICON_SIZE} color={color} aria-hidden />
|
||||
<FooterLabel c={color}>Secrets: {secretStorageLabel(info)}</FooterLabel>
|
||||
{detail && <FooterDetail c={color}>{detail}</FooterDetail>}
|
||||
</FooterBand>
|
||||
);
|
||||
|
||||
// Tooltip content: the caveat plus the path when both exist (the unencrypted
|
||||
// file case), since the visible slot can only show one of them.
|
||||
const tip = [caveat, info.path].filter(Boolean).join(" ");
|
||||
if (!tip) return band;
|
||||
|
||||
return (
|
||||
// `multiline` + a width so the sentence wraps instead of forming a tooltip
|
||||
// wider than the modal it explains.
|
||||
<Tooltip label={tip} multiline w={320} position="top" withArrow>
|
||||
{band}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -471,4 +471,46 @@ describe("ClientSettingsModal", () => {
|
|||
);
|
||||
expect(screen.queryByText("Client Settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("secret-storage footer (#1950)", () => {
|
||||
// This dialog holds the enterprise IdP client secret, so it states where
|
||||
// that secret is about to land — permanently, and beside the field rather
|
||||
// than in a startup banner nobody watching this screen ever saw.
|
||||
it("names the active store and warns when the file is unencrypted", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
secretStorage={{
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Secrets: File (unencrypted)"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no footer when the backend didn't report a store", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("secret-storage-footer")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState } from "react";
|
||||
import { CloseButton, Group, Modal, ScrollArea } from "@mantine/core";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { SecretStorageFooter } from "../../elements/SecretStorageFooter/SecretStorageFooter";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
import {
|
||||
ClientSettingsForm,
|
||||
type ClientSettingsSection,
|
||||
|
|
@ -41,6 +43,13 @@ export interface ClientSettingsModalProps {
|
|||
) => void;
|
||||
emaIdpLoginState?: EmaIdpLoginState;
|
||||
onEmaIdpLogout?: () => void;
|
||||
/**
|
||||
* Where secrets typed here end up (#1950). This dialog holds the
|
||||
* enterprise IdP client secret, so the footer is not decoration — it is
|
||||
* the answer to the question the field raises. Absent on a backend that
|
||||
* doesn't report a store; the footer then renders nothing.
|
||||
*/
|
||||
secretStorage?: SecretStorageInfo;
|
||||
}
|
||||
|
||||
export function ClientSettingsModal({
|
||||
|
|
@ -50,6 +59,7 @@ export function ClientSettingsModal({
|
|||
onSettingsChange,
|
||||
emaIdpLoginState,
|
||||
onEmaIdpLogout,
|
||||
secretStorage,
|
||||
}: ClientSettingsModalProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<
|
||||
ClientSettingsSection[]
|
||||
|
|
@ -109,6 +119,7 @@ export function ClientSettingsModal({
|
|||
revealErrors={revealErrors}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<SecretStorageFooter info={secretStorage} />
|
||||
</Modal.Content>
|
||||
</AppModalLg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -899,4 +899,44 @@ describe("ServerSettingsModal", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret-storage footer (#1950)", () => {
|
||||
// The dialog holds the OAuth client secret and every stdio `env:` value, so
|
||||
// the destination of what is typed here is stated permanently rather than
|
||||
// in a toast that has long since gone.
|
||||
it("names the active store", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
secretStorage={{
|
||||
kind: "memory",
|
||||
reason: "fallback",
|
||||
durable: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Secrets: Memory (this session only)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no footer when the backend didn't report a store", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("secret-storage-footer")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import type {
|
|||
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
|
||||
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { SecretStorageFooter } from "../../elements/SecretStorageFooter/SecretStorageFooter";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
import {
|
||||
ServerSettingsForm,
|
||||
type ServerSettingsSection,
|
||||
|
|
@ -73,6 +75,13 @@ export interface ServerSettingsModalProps {
|
|||
onClose: () => void;
|
||||
onSettingsChange: (settings: InspectorServerSettings) => void;
|
||||
onClearStoredOAuth?: () => void;
|
||||
/**
|
||||
* Where secrets typed here end up (#1950). This dialog holds the OAuth
|
||||
* client secret and every stdio `env:` value, so the footer states the
|
||||
* destination of what the user is about to type. Absent on a backend
|
||||
* that doesn't report a store; the footer then renders nothing.
|
||||
*/
|
||||
secretStorage?: SecretStorageInfo;
|
||||
}
|
||||
|
||||
export function ServerSettingsModal({
|
||||
|
|
@ -84,6 +93,7 @@ export function ServerSettingsModal({
|
|||
onClose,
|
||||
onSettingsChange,
|
||||
onClearStoredOAuth,
|
||||
secretStorage,
|
||||
}: ServerSettingsModalProps) {
|
||||
const sections = allSectionsFor(serverType, isStdio);
|
||||
// Initial expansion is the first ("options") section — where Network Log
|
||||
|
|
@ -311,6 +321,7 @@ export function ServerSettingsModal({
|
|||
onRootChange={handleRootChange}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<SecretStorageFooter info={secretStorage} />
|
||||
</Modal.Content>
|
||||
</AppModalLg>
|
||||
);
|
||||
|
|
|
|||
117
clients/web/src/test/core/auth/secret-storage-info.test.ts
Normal file
117
clients/web/src/test/core/auth/secret-storage-info.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* The secret-storage descriptor's presentation helpers (#1950).
|
||||
*
|
||||
* These are shared by the terminal banner and the browser footer, which is
|
||||
* the whole reason they live in `core/auth/` rather than beside either
|
||||
* consumer — so the cases below are about the *statements* they make,
|
||||
* particularly which states are loud and which are quiet. A helper that
|
||||
* quietly stopped warning about a plaintext file would leave both surfaces
|
||||
* silently wrong at once.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
secretStorageCaveat,
|
||||
secretStorageLabel,
|
||||
secretStorageSummary,
|
||||
secretStorageTone,
|
||||
type SecretStorageInfo,
|
||||
} from "@inspector/core/auth/secret-storage-info.js";
|
||||
|
||||
const keyring: SecretStorageInfo = {
|
||||
kind: "keyring",
|
||||
reason: "default",
|
||||
durable: true,
|
||||
};
|
||||
const encryptedFile: SecretStorageInfo = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: false,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
};
|
||||
const plaintextFile: SecretStorageInfo = {
|
||||
...encryptedFile,
|
||||
plaintext: true,
|
||||
};
|
||||
const memory: SecretStorageInfo = {
|
||||
kind: "memory",
|
||||
reason: "fallback",
|
||||
durable: false,
|
||||
};
|
||||
|
||||
describe("secretStorageLabel", () => {
|
||||
it("names the keychain", () => {
|
||||
expect(secretStorageLabel(keyring)).toBe("OS keychain");
|
||||
});
|
||||
|
||||
it("distinguishes an encrypted file from an unencrypted one", () => {
|
||||
// The distinction is the point — "File" alone would answer "where"
|
||||
// while dropping the half a user needs to judge the risk.
|
||||
expect(secretStorageLabel(encryptedFile)).toBe("File (encrypted)");
|
||||
expect(secretStorageLabel(plaintextFile)).toBe("File (unencrypted)");
|
||||
});
|
||||
|
||||
it("says memory is session-scoped in the label itself", () => {
|
||||
expect(secretStorageLabel(memory)).toBe("Memory (this session only)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretStorageTone", () => {
|
||||
it("stays neutral for the keychain and an encrypted file", () => {
|
||||
// A tone that fired for every store would carry no information.
|
||||
expect(secretStorageTone(keyring)).toBe("neutral");
|
||||
expect(secretStorageTone(encryptedFile)).toBe("neutral");
|
||||
});
|
||||
|
||||
it("warns for an unencrypted file and for memory", () => {
|
||||
expect(secretStorageTone(plaintextFile)).toBe("warn");
|
||||
expect(secretStorageTone(memory)).toBe("warn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretStorageCaveat", () => {
|
||||
it("has none for the quiet stores", () => {
|
||||
expect(secretStorageCaveat(keyring)).toBeUndefined();
|
||||
expect(secretStorageCaveat(encryptedFile)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the loss for memory", () => {
|
||||
expect(secretStorageCaveat(memory)).toContain(
|
||||
"lost when the Inspector exits",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the fix for an unencrypted file, not just the problem", () => {
|
||||
const caveat = secretStorageCaveat(plaintextFile);
|
||||
expect(caveat).toContain("unencrypted");
|
||||
expect(caveat).toContain("MCP_INSPECTOR_SECRET_KEY");
|
||||
});
|
||||
|
||||
it("prefers the memory caveat when a memory store somehow carries the flag", () => {
|
||||
// Defensive ordering: `plaintext` is meaningless for memory, and the
|
||||
// durability loss is the more consequential of the two statements.
|
||||
expect(secretStorageCaveat({ ...memory, plaintext: true })).toContain(
|
||||
"lost when the Inspector exits",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretStorageSummary", () => {
|
||||
it("is just the label when there is nothing else to say", () => {
|
||||
expect(secretStorageSummary(keyring)).toBe("OS keychain");
|
||||
});
|
||||
|
||||
it("includes the path so a file-backed store is findable", () => {
|
||||
expect(secretStorageSummary(encryptedFile)).toContain(
|
||||
"/home/node/.mcp-inspector/secrets.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a fallback as a fallback", () => {
|
||||
// The banner has to distinguish "you configured this" from "we had to".
|
||||
expect(secretStorageSummary(memory)).toContain("fell back");
|
||||
expect(
|
||||
secretStorageSummary({ ...memory, reason: "configured" }),
|
||||
).not.toContain("fell back");
|
||||
});
|
||||
});
|
||||
|
|
@ -249,4 +249,55 @@ describe("useInitialConfig", () => {
|
|||
expect(result.current.version).toBeUndefined();
|
||||
expect(result.current.writable).toBe(true);
|
||||
});
|
||||
|
||||
describe("secretStorage (#1950)", () => {
|
||||
// The footer under a secret field reads this, so the hook's job is to hand
|
||||
// back only a descriptor it can vouch for. Every rejected shape must land
|
||||
// on `undefined` — which the footer renders as nothing — rather than on a
|
||||
// half-populated object that would render a confident wrong answer.
|
||||
it("passes through a well-formed descriptor", async () => {
|
||||
const info = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
};
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ secretStorage: info }));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.secretStorage).toEqual(info);
|
||||
});
|
||||
|
||||
it("is undefined on a backend that omits the field", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
||||
const { result } = renderHook(() =>
|
||||
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.secretStorage).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-object", "keyring"],
|
||||
["null", null],
|
||||
["an unknown kind", { kind: "vault", durable: true }],
|
||||
["a missing kind", { durable: true, path: "/x" }],
|
||||
])("rejects %s", async (_label, value) => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ secretStorage: value }));
|
||||
const { result } = renderHook(() =>
|
||||
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.secretStorage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
/**
|
||||
* `FileSecretStore` against a real filesystem (#1950).
|
||||
*
|
||||
* A real temp directory rather than a mocked `fs`: the properties worth
|
||||
* asserting here are properties *of the file* — its mode, that a second
|
||||
* store reading it back sees the same secrets, that the ciphertext does
|
||||
* not contain the plaintext — and a mocked filesystem can be made to
|
||||
* agree with any of those without them being true.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
FileSecretStore,
|
||||
SecretFileKeyMismatchError,
|
||||
tightenSecretFilePermissions,
|
||||
} from "@inspector/core/auth/node/file-secret-store.js";
|
||||
import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js";
|
||||
import { SECRET_FIELD_OAUTH_CLIENT_SECRET } from "@inspector/core/auth/secret-fields.js";
|
||||
import { describeSecretStoreContract } from "./secretStoreContract.js";
|
||||
|
||||
let tmpDir: string;
|
||||
const filePath = (): string => path.join(tmpDir, "secrets.json");
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "inspector-secrets-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Both modes must satisfy the same contract — encryption is a property of
|
||||
// the bytes on disk, not of the interface, and a caller must not be able to
|
||||
// tell which one it got.
|
||||
describeSecretStoreContract(
|
||||
"FileSecretStore (plaintext)",
|
||||
() => new FileSecretStore({ filePath: filePath() }),
|
||||
);
|
||||
describeSecretStoreContract(
|
||||
"FileSecretStore (encrypted)",
|
||||
() => new FileSecretStore({ filePath: filePath(), passphrase: "hunter2" }),
|
||||
);
|
||||
|
||||
describe("FileSecretStore persistence", () => {
|
||||
it("a second store instance reads what the first wrote", async () => {
|
||||
const a = new FileSecretStore({ filePath: filePath() });
|
||||
await a.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
const b = new FileSecretStore({ filePath: filePath() });
|
||||
expect(await b.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("shh");
|
||||
});
|
||||
|
||||
it("round-trips through a real encrypt/decrypt cycle across instances", async () => {
|
||||
const a = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
await a.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
const b = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
expect(await b.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("shh");
|
||||
});
|
||||
|
||||
it("writes the file 0600", async () => {
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
const stat = await fs.stat(filePath());
|
||||
// Windows does not model POSIX modes; asserting there would fail for a
|
||||
// reason that has nothing to do with this code.
|
||||
if (process.platform !== "win32") {
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("concurrent sets do not lose each other (the read-modify-write race)", async () => {
|
||||
// The route handlers write a server's fields with `Promise.all`, so this
|
||||
// is the ordinary path. Without the internal queue both calls read the
|
||||
// same empty map and the second write drops the first entry.
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await Promise.all([
|
||||
store.set("alpha", "env:A", "1"),
|
||||
store.set("alpha", "env:B", "2"),
|
||||
store.set("alpha", "env:C", "3"),
|
||||
]);
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
expect(await store.get("alpha", "env:B")).toBe("2");
|
||||
expect(await store.get("alpha", "env:C")).toBe("3");
|
||||
});
|
||||
|
||||
it("keeps the file after the last entry is deleted", async () => {
|
||||
// The file's presence records which encryption mode this install settled
|
||||
// on; unlinking it would let a later write silently switch modes.
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
await store.deleteAllForServer("alpha");
|
||||
await expect(fs.stat(filePath())).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileSecretStore at rest", () => {
|
||||
it("plaintext mode stores the value readably (the state the warning is about)", async () => {
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
expect(store.encrypted).toBe(false);
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "super-secret");
|
||||
const raw = await fs.readFile(filePath(), "utf-8");
|
||||
expect(raw).toContain("super-secret");
|
||||
expect(JSON.parse(raw).encryption).toBe("none");
|
||||
});
|
||||
|
||||
it("encrypted mode leaves neither the value nor the account name on disk", async () => {
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
expect(store.encrypted).toBe(true);
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "super-secret");
|
||||
const raw = await fs.readFile(filePath(), "utf-8");
|
||||
expect(raw).not.toContain("super-secret");
|
||||
// The account names are encrypted too — that's why the map is encrypted
|
||||
// as a unit rather than value-by-value, since the key list would
|
||||
// otherwise index which servers hold which kind of secret.
|
||||
expect(raw).not.toContain("alpha");
|
||||
expect(raw).not.toContain(SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.encryption).toBe("aes-256-gcm");
|
||||
expect(parsed.kdf.algorithm).toBe("scrypt");
|
||||
});
|
||||
|
||||
it("treats a blank passphrase as no passphrase", async () => {
|
||||
// `MCP_INSPECTOR_SECRET_KEY=` in a compose file means "off", not a
|
||||
// zero-length key — and silently encrypting with "" would be worse than
|
||||
// either reading.
|
||||
expect(
|
||||
new FileSecretStore({ filePath: filePath(), passphrase: " " })
|
||||
.encrypted,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("upgrades an existing plaintext file to encrypted on the next write", async () => {
|
||||
const plain = new FileSecretStore({ filePath: filePath() });
|
||||
await plain.set("alpha", "env:A", "1");
|
||||
const encrypted = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
// Reads the plaintext file it inherited...
|
||||
expect(await encrypted.get("alpha", "env:A")).toBe("1");
|
||||
// ...and the next write carries the old entry into the encrypted form,
|
||||
// so setting the env var does not strand the secrets already stored.
|
||||
await encrypted.set("alpha", "env:B", "2");
|
||||
const raw = await fs.readFile(filePath(), "utf-8");
|
||||
expect(JSON.parse(raw).encryption).toBe("aes-256-gcm");
|
||||
expect(await encrypted.get("alpha", "env:A")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileSecretStore failure handling", () => {
|
||||
const writeEncryptedFixture = async (): Promise<void> => {
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
};
|
||||
|
||||
it("get returns null when the passphrase is wrong (read tolerance)", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("set refuses rather than overwriting a file it cannot decrypt", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretFileKeyMismatchError,
|
||||
);
|
||||
// The original secrets survive: the refusal is the whole point.
|
||||
const original = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
expect(await original.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("set names the missing passphrase when the key was removed entirely", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/MCP_INSPECTOR_SECRET_KEY is not set/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a plaintext file that carries no secrets key as empty", async () => {
|
||||
// A hand-edited (or hand-created) file is the realistic source of this
|
||||
// shape, and it must read as "no secrets yet" rather than throwing: the
|
||||
// document is well-formed and declares its encryption mode, so the only
|
||||
// thing missing is entries. Flattening it to `{}` is also what lets the
|
||||
// next `set` add one instead of refusing.
|
||||
await fs.writeFile(
|
||||
filePath(),
|
||||
JSON.stringify({ version: 1, encryption: "none" }),
|
||||
"utf-8",
|
||||
);
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete stays silent on a file it cannot decrypt", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
await expect(
|
||||
store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("set reports a corrupt file rather than silently replacing it", async () => {
|
||||
await fs.writeFile(filePath(), "{ not json", "utf-8");
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretStoreUnavailableError,
|
||||
);
|
||||
expect(await store.get("alpha", "env:A")).toBe(null);
|
||||
});
|
||||
|
||||
it("set reports a file written in a format this build doesn't know", async () => {
|
||||
// The forward-compat case: a newer Inspector's file, opened by an older
|
||||
// one. Refusing beats truncating it to the entries we happen to parse.
|
||||
await fs.writeFile(
|
||||
filePath(),
|
||||
JSON.stringify({ version: 99, encryption: "chacha20-poly1305" }),
|
||||
"utf-8",
|
||||
);
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/unsupported format/,
|
||||
);
|
||||
});
|
||||
|
||||
it("set reports a truncated ciphertext payload", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const parsed = JSON.parse(await fs.readFile(filePath(), "utf-8"));
|
||||
parsed.data = "only-one-part";
|
||||
await fs.writeFile(filePath(), JSON.stringify(parsed), "utf-8");
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretFileKeyMismatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a file whose KDF parameters are unusable", async () => {
|
||||
// scrypt rejects on out-of-grammar cost parameters (`N` must be a power of
|
||||
// two > 1). Those parameters come off disk, so this is a corrupt-file case
|
||||
// reachable without touching the crypto call site — and it must land as a
|
||||
// refusal, not as an exception escaping the derivation.
|
||||
await writeEncryptedFixture();
|
||||
const parsed = JSON.parse(await fs.readFile(filePath(), "utf-8"));
|
||||
parsed.kdf.N = 3;
|
||||
await fs.writeFile(filePath(), JSON.stringify(parsed), "utf-8");
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretFileKeyMismatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a read failure that isn't a missing file", async () => {
|
||||
// A directory where the file should be: `readStoreFile` only translates
|
||||
// ENOENT into "no file yet", so anything else must surface as a refusal
|
||||
// rather than being read as an empty store and overwritten.
|
||||
const asDir = path.join(tmpDir, "as-dir");
|
||||
await fs.mkdir(asDir);
|
||||
const store = new FileSecretStore({ filePath: asDir });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretStoreUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it("a rejected set leaves the same store usable for later operations", async () => {
|
||||
// The internal queue swallows failures on its chain so one rejection can't
|
||||
// strand every subsequent call on that store — the returned promise still
|
||||
// rejects, which is what the caller sees.
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow();
|
||||
expect(await store.get("alpha", "env:A")).toBe(null);
|
||||
await expect(store.delete("alpha", "env:A")).resolves.toBeUndefined();
|
||||
await expect(store.set("alpha", "env:B", "2")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("delete is a no-op when the file exists but holds no matching entry", async () => {
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", "env:A", "1");
|
||||
await store.delete("beta", "env:A");
|
||||
await store.deleteAllForServer("gamma");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
});
|
||||
|
||||
it("set reports an unreachable location instead of failing silently", async () => {
|
||||
// A path whose parent is a *file*: the read fails with ENOTDIR rather
|
||||
// than the ENOENT that means "no file yet", so this lands on the read
|
||||
// refusal. It is the class the 503 exists for — fixable outside the
|
||||
// process.
|
||||
const blocker = path.join(tmpDir, "blocker");
|
||||
await fs.writeFile(blocker, "x", "utf-8");
|
||||
const store = new FileSecretStore({
|
||||
filePath: path.join(blocker, "secrets.json"),
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/Could not read the secrets file/,
|
||||
);
|
||||
});
|
||||
|
||||
// A read that succeeds (or reports "no file yet") followed by a write that
|
||||
// fails — a read-only mount, a full disk, a permissions change since
|
||||
// startup. Reproduced with a non-writable directory, which requires not
|
||||
// being root: `chmod` is advisory to uid 0, so under root the write would
|
||||
// succeed and the assertion would be wrong rather than merely unexercised.
|
||||
// CI runs as an unprivileged user, which is where the coverage gate is
|
||||
// enforced.
|
||||
const asRoot = process.getuid?.() === 0;
|
||||
it.skipIf(asRoot || process.platform === "win32")(
|
||||
"set reports a write failure distinctly from a read failure",
|
||||
async () => {
|
||||
const dir = path.join(tmpDir, "locked");
|
||||
await fs.mkdir(dir);
|
||||
await fs.chmod(dir, 0o555);
|
||||
try {
|
||||
const store = new FileSecretStore({
|
||||
filePath: path.join(dir, "secrets.json"),
|
||||
});
|
||||
// Nothing is there to read, so the failure is unambiguously the write.
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/Could not write the secrets file/,
|
||||
);
|
||||
} finally {
|
||||
// Restore write permission or the afterEach `rm -rf` can't clean up.
|
||||
await fs.chmod(dir, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("a rejected set does not poison later sets on the same store", async () => {
|
||||
// The internal queue swallows on the chain but not on the returned
|
||||
// promise; without that, one failure would strand every subsequent write.
|
||||
const blocked = path.join(tmpDir, "blocker");
|
||||
await fs.writeFile(blocked, "x", "utf-8");
|
||||
const bad = new FileSecretStore({
|
||||
filePath: path.join(blocked, "secrets.json"),
|
||||
});
|
||||
await expect(bad.set("alpha", "env:A", "1")).rejects.toThrow();
|
||||
|
||||
// A different store on a good path still works, and so does a second
|
||||
// write on the failing one's successor.
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", "env:A", "1");
|
||||
await store.set("alpha", "env:B", "2");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
expect(await store.get("alpha", "env:B")).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tightenSecretFilePermissions", () => {
|
||||
it("repairs a loosened mode on an existing file", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await fs.writeFile(filePath(), "{}", "utf-8");
|
||||
await fs.chmod(filePath(), 0o644);
|
||||
await tightenSecretFilePermissions(filePath());
|
||||
expect((await fs.stat(filePath())).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("is a no-op for a file that doesn't exist yet (the first-run case)", async () => {
|
||||
await expect(
|
||||
tightenSecretFilePermissions(path.join(tmpDir, "nope.json")),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* The store-selection policy (#1950).
|
||||
*
|
||||
* The two questions this file exists to pin down are the ones a reviewer
|
||||
* would want proof of, because getting either wrong silently changes where
|
||||
* a user's secret goes:
|
||||
*
|
||||
* 1. an unreachable keychain falls back rather than failing, and says so;
|
||||
* 2. the fallback is `memory` in a container with nothing durable
|
||||
* mounted, and `file` otherwise.
|
||||
*
|
||||
* `resolveSecretStore` caches its answer for the process, so each case
|
||||
* that exercises it goes through `vi.resetModules()` + a fresh dynamic
|
||||
* import — the same technique the keyring-unloadable cases in
|
||||
* `secret-store.test.ts` use, and for the same reason. The keychain probe
|
||||
* is mocked because CI has no libsecret; the container and mount
|
||||
* predicates are driven directly rather than through a real container,
|
||||
* which is what makes them worth exporting.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
chooseFallbackKind,
|
||||
isOnMountPoint,
|
||||
parseSecretStoreEnv,
|
||||
warnAboutSecretStorage,
|
||||
} from "@inspector/core/auth/node/secret-store-selection.js";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"MCP_INSPECTOR_SECRET_STORE",
|
||||
"MCP_INSPECTOR_SECRET_FILE",
|
||||
"MCP_INSPECTOR_SECRET_KEY",
|
||||
"KUBERNETES_SERVICE_HOST",
|
||||
];
|
||||
|
||||
let saved: Record<string, string | undefined>;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "inspector-selection-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Load a fresh copy of the selection module with the keychain probe forced
|
||||
* to a given answer. Fresh because the resolution is cached per module
|
||||
* instance, which is exactly the behavior we don't want leaking between
|
||||
* cases.
|
||||
*/
|
||||
async function loadWithProbe(available: boolean, detail = "no D-Bus session") {
|
||||
vi.resetModules();
|
||||
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 ? { available: true } : { available: false, detail },
|
||||
),
|
||||
};
|
||||
});
|
||||
return import("@inspector/core/auth/node/secret-store-selection.js");
|
||||
}
|
||||
|
||||
describe("parseSecretStoreEnv", () => {
|
||||
it("accepts each kind, case- and whitespace-insensitively", () => {
|
||||
expect(parseSecretStoreEnv("keyring")).toBe("keyring");
|
||||
expect(parseSecretStoreEnv(" FILE ")).toBe("file");
|
||||
expect(parseSecretStoreEnv("Memory")).toBe("memory");
|
||||
});
|
||||
|
||||
it("treats unset and blank as unset", () => {
|
||||
expect(parseSecretStoreEnv(undefined)).toBeUndefined();
|
||||
expect(parseSecretStoreEnv(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns and falls through on a typo rather than refusing to start", () => {
|
||||
// A misspelled env var in a compose file should not be the reason the
|
||||
// Inspector won't boot — the automatic policy is a working default.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(parseSecretStoreEnv("keychain")).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("keyring, file, memory"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chooseFallbackKind", () => {
|
||||
it("uses memory in a container with nothing mounted", () => {
|
||||
// The honest answer: a file in the writable layer is discarded by
|
||||
// `docker run --rm` and by any image update, so promising durability
|
||||
// there is worse than declining to.
|
||||
expect(chooseFallbackKind({ container: true, mounted: false })).toBe(
|
||||
"memory",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses file in a container once a volume is mounted", () => {
|
||||
// Mounting the volume *is* the user saying this directory should
|
||||
// survive, so no extra configuration should be needed to honor it.
|
||||
expect(chooseFallbackKind({ container: true, mounted: true })).toBe("file");
|
||||
});
|
||||
|
||||
it("uses file outside a container, mounted or not", () => {
|
||||
expect(chooseFallbackKind({ container: false, mounted: false })).toBe(
|
||||
"file",
|
||||
);
|
||||
expect(chooseFallbackKind({ container: false, mounted: true })).toBe(
|
||||
"file",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isContainer", () => {
|
||||
/**
|
||||
* Load the module with `node:fs` stubbed, so each detection signal can be
|
||||
* exercised on a developer's laptop. The three exist because no single one
|
||||
* covers the runtimes people use — Docker, Podman/containerd, and
|
||||
* Kubernetes each announce themselves differently.
|
||||
*/
|
||||
async function loadWithFs(opts: {
|
||||
dockerenv?: boolean;
|
||||
cgroup?: string | Error;
|
||||
}) {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (p: string) =>
|
||||
p === "/.dockerenv" ? !!opts.dockerenv : actual.existsSync(p),
|
||||
readFileSync: (p: string, enc?: unknown) => {
|
||||
if (p === "/proc/1/cgroup") {
|
||||
if (opts.cgroup instanceof Error) throw opts.cgroup;
|
||||
if (opts.cgroup === undefined) throw new Error("ENOENT");
|
||||
return opts.cgroup;
|
||||
}
|
||||
return actual.readFileSync(p, enc as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
return import("@inspector/core/auth/node/secret-store-selection.js");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("node:fs");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("is true inside Kubernetes, without touching the filesystem", async () => {
|
||||
process.env.KUBERNETES_SERVICE_HOST = "10.0.0.1";
|
||||
const mod = await loadWithFs({});
|
||||
expect(mod.isContainer()).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when /.dockerenv exists", async () => {
|
||||
const mod = await loadWithFs({ dockerenv: true });
|
||||
expect(mod.isContainer()).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when a container runtime appears in /proc/1/cgroup", async () => {
|
||||
const mod = await loadWithFs({
|
||||
cgroup: "0::/system.slice/containerd.service\n",
|
||||
});
|
||||
expect(mod.isContainer()).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a host cgroup line", async () => {
|
||||
const mod = await loadWithFs({
|
||||
cgroup: "0::/user.slice/user-1000.slice\n",
|
||||
});
|
||||
expect(mod.isContainer()).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when neither signal is readable (macOS, or no /proc)", async () => {
|
||||
// Not a container as far as we know — and a false negative is the cheap
|
||||
// direction, since it only biases the fallback between two working stores.
|
||||
const mod = await loadWithFs({ cgroup: new Error("ENOENT") });
|
||||
expect(mod.isContainer()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOnMountPoint", () => {
|
||||
it("is false for an ordinary directory", () => {
|
||||
expect(isOnMountPoint(tmpDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a path that doesn't exist yet, walking to its parent", () => {
|
||||
// The first-run shape: `~/.mcp-inspector` has not been created. Answering
|
||||
// from the nearest existing ancestor is what keeps this from being a
|
||||
// missing-file error at startup.
|
||||
expect(isOnMountPoint(path.join(tmpDir, "nope", "deeper"))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the path itself can't be resolved", () => {
|
||||
// A NUL byte makes `path.resolve` throw. Any failure to answer the
|
||||
// question resolves to "not a mount", which keeps a container on the
|
||||
// conservative side — memory, which never promises what it can't keep.
|
||||
expect(isOnMountPoint("bad\0path")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false at the filesystem root", () => {
|
||||
// `/` is its own parent and is trivially a mount — reporting true there
|
||||
// would make every container look like it had a volume.
|
||||
expect(isOnMountPoint(path.parse(process.cwd()).root)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnAboutSecretStorage", () => {
|
||||
const base: SecretStorageInfo = {
|
||||
kind: "keyring",
|
||||
reason: "default",
|
||||
durable: true,
|
||||
};
|
||||
|
||||
it("says nothing for the ordinary keychain case", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage(base);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces a fallback, names the cause, and points at the override", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage({
|
||||
kind: "memory",
|
||||
reason: "fallback",
|
||||
durable: false,
|
||||
detail: "no D-Bus session",
|
||||
});
|
||||
const output = warn.mock.calls.flat().join("\n");
|
||||
expect(output).toContain("OS keychain is not available");
|
||||
expect(output).toContain("no D-Bus session");
|
||||
expect(output).toContain("MCP_INSPECTOR_SECRET_STORE");
|
||||
// The lossy-store caveat rides along — this is the "loud" half of
|
||||
// "automatic fallback with a loud banner".
|
||||
expect(output).toContain("lost when the Inspector exits");
|
||||
});
|
||||
|
||||
it("warns about an unencrypted file even when it was explicitly chosen", () => {
|
||||
// `reason: "configured"` means the user picked `file`; it does not mean
|
||||
// they knew it would be unencrypted.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage({
|
||||
kind: "file",
|
||||
reason: "configured",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/tmp/secrets.json",
|
||||
});
|
||||
expect(warn.mock.calls.flat().join("\n")).toContain(
|
||||
"MCP_INSPECTOR_SECRET_KEY",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSecretStore", () => {
|
||||
it("uses the keychain when the probe succeeds", async () => {
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info).toMatchObject({ kind: "keyring", reason: "default" });
|
||||
});
|
||||
|
||||
it("caches the resolution so every consumer describes the same store", async () => {
|
||||
// The banner, `/api/config`, and the store doing the writing are three
|
||||
// readers of one decision; a re-probe that flipped would have the UI
|
||||
// naming a store nobody is using.
|
||||
const mod = await loadWithProbe(true);
|
||||
expect(await mod.resolveSecretStore()).toBe(await mod.resolveSecretStore());
|
||||
});
|
||||
|
||||
it("falls back with a reason and a detail when the probe fails", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
|
||||
const mod = await loadWithProbe(false, "Couldn't access platform storage");
|
||||
const { info, store } = await mod.resolveSecretStore();
|
||||
expect(info.reason).toBe("fallback");
|
||||
expect(info.detail).toContain("Couldn't access platform storage");
|
||||
// And the store it hands back actually works — the point of falling back.
|
||||
await store.set("alpha", "env:A", "1");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
});
|
||||
|
||||
it("honors an explicit memory store without probing", async () => {
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "memory";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info).toMatchObject({
|
||||
kind: "memory",
|
||||
reason: "configured",
|
||||
durable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit file store, reporting its path and encryption state", async () => {
|
||||
const target = path.join(tmpDir, "custom", "secrets.json");
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = target;
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info).toMatchObject({
|
||||
kind: "file",
|
||||
reason: "configured",
|
||||
path: target,
|
||||
plaintext: true,
|
||||
durable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an encrypted file when the passphrase is set", async () => {
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
|
||||
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info.plaintext).toBe(false);
|
||||
});
|
||||
|
||||
it("getSecretStorageInfo returns the same descriptor as the resolution", async () => {
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(await mod.getSecretStorageInfo()).toBe(info);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultSecretStore", () => {
|
||||
it("delegates every operation to the resolved store", async () => {
|
||||
// The deferred wrapper exists so the synchronous `?? defaultSecretStore()`
|
||||
// call sites keep working; if it stopped forwarding, secrets would vanish
|
||||
// into an object nobody reads.
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "memory";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const store = mod.defaultSecretStore();
|
||||
|
||||
await store.set("alpha", "env:A", "1");
|
||||
await store.set("alpha", "env:B", "2");
|
||||
await store.set("beta", "env:A", "3");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
|
||||
await store.delete("alpha", "env:A");
|
||||
expect(await store.get("alpha", "env:A")).toBe(null);
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
expect(await store.get("alpha", "env:B")).toBe(null);
|
||||
expect(await store.get("beta", "env:A")).toBe("3");
|
||||
|
||||
// And it is the *same* underlying store the resolution reports.
|
||||
const { store: resolvedStore } = await mod.resolveSecretStore();
|
||||
expect(await resolvedStore.get("beta", "env:A")).toBe("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultSecretFilePath", () => {
|
||||
it("resolves a relative override to an absolute path", async () => {
|
||||
// The path is shown in the UI footer, so a relative one would be
|
||||
// ambiguous about which directory it is relative to.
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = "./rel/secrets.json";
|
||||
const mod = await loadWithProbe(true);
|
||||
expect(path.isAbsolute(mod.defaultSecretFilePath())).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to ~/.mcp-inspector/secrets.json", async () => {
|
||||
const mod = await loadWithProbe(true);
|
||||
expect(mod.defaultSecretFilePath()).toContain(
|
||||
path.join(".mcp-inspector", "secrets.json"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
* its own describe built on `vi.resetModules()`.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { describeSecretStoreContract } from "./secretStoreContract.js";
|
||||
|
||||
// The mock must be hoisted above the `await import` of secret-store
|
||||
// inside the `KeyringSecretStore` describe block. Use `vi.hoisted` so
|
||||
|
|
@ -24,6 +25,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||
const keyringMocks = vi.hoisted(() => {
|
||||
const password = new Map<string, string | null>();
|
||||
const reset = () => password.clear();
|
||||
const storedAccounts = (): string[] => [...password.keys()];
|
||||
// Behavior hooks each test can flip to simulate keychain unavailability
|
||||
// on specific operations. Defaults: real-ish in-memory behavior.
|
||||
const failures = {
|
||||
|
|
@ -77,7 +79,14 @@ const keyringMocks = vi.hoisted(() => {
|
|||
if (failures.findThrows) throw new Error("keychain find unavailable");
|
||||
return credentials();
|
||||
};
|
||||
return { AsyncEntry, findCredentialsAsync, failures, password, reset };
|
||||
return {
|
||||
AsyncEntry,
|
||||
findCredentialsAsync,
|
||||
failures,
|
||||
password,
|
||||
reset,
|
||||
storedAccounts,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@napi-rs/keyring", () => ({
|
||||
|
|
@ -90,115 +99,20 @@ import {
|
|||
KeyringSecretStore,
|
||||
KeychainUnavailableError,
|
||||
KeyringModuleShapeError,
|
||||
probeKeyringAvailable,
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
envSecretField,
|
||||
parseAccount,
|
||||
type SecretStore,
|
||||
} from "@inspector/core/auth/node/secret-store.js";
|
||||
|
||||
describe("InMemorySecretStore", () => {
|
||||
let store: SecretStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new InMemorySecretStore();
|
||||
});
|
||||
|
||||
it("returns null for a missing entry", async () => {
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips a value set then get", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different server ids as separate namespaces", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "alpha-secret");
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "beta-secret");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"alpha-secret",
|
||||
);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"beta-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different fields under the same server id as separate entries", async () => {
|
||||
await store.set("alpha", envSecretField("API_KEY"), "k1");
|
||||
await store.set("alpha", envSecretField("DB_PASS"), "k2");
|
||||
expect(await store.get("alpha", envSecretField("API_KEY"))).toBe("k1");
|
||||
expect(await store.get("alpha", envSecretField("DB_PASS"))).toBe("k2");
|
||||
});
|
||||
|
||||
it("overwrites an existing entry on set", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v1");
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v2");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"v2",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete is a no-op for a missing entry", async () => {
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
// No throw, no state change.
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes only the targeted (id, field)", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY"), "b");
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY"))).toBe("b");
|
||||
});
|
||||
|
||||
it("deleteAllForServer removes every field under that id", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY1"), "b");
|
||||
await store.set("alpha", envSecretField("KEY2"), "c");
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY1"))).toBe(null);
|
||||
expect(await store.get("alpha", envSecretField("KEY2"))).toBe(null);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"untouched",
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteAllForServer does not delete entries on a different id that happens to share a prefix", async () => {
|
||||
// The account scheme is `${serverId}:${field}` — a literal prefix match
|
||||
// would incorrectly sweep "alpha-prime" entries when deleting "alpha".
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET),
|
||||
).toBe("p");
|
||||
});
|
||||
|
||||
it("round-trips an empty-string value (set + get returns '')", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("");
|
||||
});
|
||||
});
|
||||
// The generic cases live in `secretStoreContract.ts` and are shared with
|
||||
// `FileSecretStore` (#1950) — a contract asserted against one implementation
|
||||
// is not a contract. `KeyringSecretStore` keeps its own (failure-focused)
|
||||
// block below, since it has no reachable backend in CI.
|
||||
describeSecretStoreContract(
|
||||
"InMemorySecretStore",
|
||||
() => new InMemorySecretStore(),
|
||||
);
|
||||
|
||||
describe("parseAccount", () => {
|
||||
it("splits `${serverId}:${field}` on the first colon", () => {
|
||||
|
|
@ -332,6 +246,22 @@ describe("KeyringSecretStore (mocked native bindings)", () => {
|
|||
).toBe("p");
|
||||
});
|
||||
|
||||
it("deleteAllForServer skips an account it cannot parse", async () => {
|
||||
// `findCredentialsAsync` returns whatever is in the keychain, including
|
||||
// an entry this build didn't write. An empty field yields the account
|
||||
// "alpha:", which `parseAccount` rejects — the guard is what keeps a
|
||||
// stray entry from being treated as belonging to this server.
|
||||
await store.set("alpha", "", "orphan");
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "real");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", "")).toBe("orphan");
|
||||
});
|
||||
|
||||
it("KeychainUnavailableError stringifies a non-Error cause", () => {
|
||||
const err = new KeychainUnavailableError("plain string cause");
|
||||
expect(err).toBeInstanceOf(KeychainUnavailableError);
|
||||
|
|
@ -447,6 +377,44 @@ describe("KeyringSecretStore (mocked native bindings)", () => {
|
|||
await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeKeyringAvailable (#1950)", () => {
|
||||
// The input to the automatic-fallback policy. It has to answer "is the
|
||||
// keychain *usable*", not "did the package import" — the container that
|
||||
// motivated #1848 imports the package fine and only fails when
|
||||
// `AsyncEntry::new` reaches for a Secret Service that isn't there.
|
||||
it("reports available when a read round-trips", async () => {
|
||||
await expect(probeKeyringAvailable()).resolves.toEqual({
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unavailable, with the cause, when construction throws", async () => {
|
||||
keyringMocks.failures.constructorThrows = true;
|
||||
const result = await probeKeyringAvailable();
|
||||
expect(result.available).toBe(false);
|
||||
// The detail is what the fallback banner prints, and the three
|
||||
// realistic causes need different fixes — so it is surfaced rather
|
||||
// than summarized.
|
||||
expect(result).toHaveProperty("detail");
|
||||
expect((result as { detail: string }).detail).toContain(
|
||||
"Couldn't access platform storage",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports unavailable when the read itself throws", async () => {
|
||||
keyringMocks.failures.getThrows = true;
|
||||
expect((await probeKeyringAvailable()).available).toBe(false);
|
||||
});
|
||||
|
||||
it("never writes to the user's keychain", async () => {
|
||||
// Deliberate: a write probe would be a stronger signal but would
|
||||
// deposit a value in someone's login keyring at every startup, for a
|
||||
// store they may never use.
|
||||
await probeKeyringAvailable();
|
||||
expect(keyringMocks.storedAccounts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("@napi-rs/keyring unloadable on this platform (#1905)", () => {
|
||||
|
|
@ -539,6 +507,21 @@ describe("@napi-rs/keyring unloadable on this platform (#1905)", () => {
|
|||
await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("the availability probe reports unavailable and names the load error", async () => {
|
||||
// The fallback policy's input on Android/Termux: nothing to probe, so
|
||||
// the answer must come from the load failure rather than from an
|
||||
// attempted construction that would throw somewhere less legible.
|
||||
const mod = await importWithUnloadableKeyring();
|
||||
const result = await mod.probeKeyringAvailable();
|
||||
expect(result.available).toBe(false);
|
||||
// The detail carries the loader's own message. Asserted as non-empty
|
||||
// rather than by content: vitest replaces whatever a throwing `doMock`
|
||||
// factory threw with its own "error when mocking a module" text (see the
|
||||
// hint-branch note above), so the literal `Cannot find module` string
|
||||
// never reaches us here even though it does in production.
|
||||
expect((result as { detail: string }).detail).not.toBe("");
|
||||
});
|
||||
|
||||
it("attempts the load once and caches the failure", async () => {
|
||||
// Without the cache every secret operation re-attempts (and re-throws)
|
||||
// the resolution — `expectedSecretFields` means that is once per
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* The behavioral contract every `SecretStore` implementation must satisfy,
|
||||
* as a reusable block of `it()`s.
|
||||
*
|
||||
* These assertions were written once against `InMemorySecretStore` and are
|
||||
* now shared with `FileSecretStore` (#1950), which is the point: the whole
|
||||
* design rests on callers not knowing which store they got, and a contract
|
||||
* asserted against only one implementation cannot support that claim. The
|
||||
* cases that matter most are the two that look like edge cases and are
|
||||
* not — the empty-string value (a real value that must round-trip, which
|
||||
* is why the interface returns `null` for "absent" rather than falsy) and
|
||||
* the shared-prefix server id (`alpha` vs `alpha-prime`, where a literal
|
||||
* prefix sweep in `deleteAllForServer` deletes the wrong server's
|
||||
* secrets).
|
||||
*
|
||||
* `KeyringSecretStore` is deliberately *not* run through this block: it
|
||||
* has no reachable backend in CI, so its own file drives a mocked native
|
||||
* layer and asserts the failure half of the contract instead.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { SecretStore } from "@inspector/core/auth/node/secret-store.js";
|
||||
import {
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
envSecretField,
|
||||
} from "@inspector/core/auth/secret-fields.js";
|
||||
|
||||
/**
|
||||
* @param name Suite label.
|
||||
* @param makeStore Produces a *fresh, empty* store. Called per test — a
|
||||
* file-backed implementation uses this to point at a new temp path, so
|
||||
* the shared cases stay ignorant of where the state lives.
|
||||
*/
|
||||
export function describeSecretStoreContract(
|
||||
name: string,
|
||||
makeStore: () => SecretStore | Promise<SecretStore>,
|
||||
): void {
|
||||
describe(`${name} (SecretStore contract)`, () => {
|
||||
let store: SecretStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await makeStore();
|
||||
});
|
||||
|
||||
it("returns null for a missing entry", async () => {
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips a value set then get", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different server ids as separate namespaces", async () => {
|
||||
await store.set(
|
||||
"alpha",
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
"alpha-secret",
|
||||
);
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "beta-secret");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"alpha-secret",
|
||||
);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"beta-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different fields under the same server id as separate entries", async () => {
|
||||
await store.set("alpha", envSecretField("API_KEY"), "k1");
|
||||
await store.set("alpha", envSecretField("DB_PASS"), "k2");
|
||||
expect(await store.get("alpha", envSecretField("API_KEY"))).toBe("k1");
|
||||
expect(await store.get("alpha", envSecretField("DB_PASS"))).toBe("k2");
|
||||
});
|
||||
|
||||
it("overwrites an existing entry on set", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v1");
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v2");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"v2",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete is a no-op for a missing entry", async () => {
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
// No throw, no state change.
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes only the targeted (id, field)", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY"), "b");
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY"))).toBe("b");
|
||||
});
|
||||
|
||||
it("deleteAllForServer removes every field under that id", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY1"), "b");
|
||||
await store.set("alpha", envSecretField("KEY2"), "c");
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY1"))).toBe(null);
|
||||
expect(await store.get("alpha", envSecretField("KEY2"))).toBe(null);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"untouched",
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteAllForServer does not delete entries on a different id that happens to share a prefix", async () => {
|
||||
// The account scheme is `${serverId}:${field}` — a literal prefix match
|
||||
// would incorrectly sweep "alpha-prime" entries when deleting "alpha".
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET),
|
||||
).toBe("p");
|
||||
});
|
||||
|
||||
it("round-trips an empty-string value (set + get returns '')", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue