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>
124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
/**
|
|
* The description of *where a typed secret ends up* — the one fact this
|
|
* whole subsystem exists to make unmissable (#1950).
|
|
*
|
|
* Pure data plus its presentation helpers, deliberately kept out of
|
|
* `core/auth/node/` so the browser can import it. The Node side resolves
|
|
* the active store (`core/auth/node/secret-store-selection.ts`) and
|
|
* produces one of these; it then travels three ways, and all three read
|
|
* the same object so they cannot disagree:
|
|
*
|
|
* 1. the startup banner the launcher prints,
|
|
* 2. `GET /api/config` → the web UI's settings-modal footers,
|
|
* 3. the `SecretStore` doc comment's contract, in prose.
|
|
*
|
|
* A store's *kind* answers "where", `plaintext` answers "readable by
|
|
* anyone who can read the file", and `durable` answers "will it still be
|
|
* there next run". Those are three separate questions and a user asking
|
|
* one of them is rarely helped by the answer to another, so none is
|
|
* folded into the others.
|
|
*/
|
|
|
|
/** Which backend is holding secrets this session. */
|
|
export type SecretStoreKind = "keyring" | "file" | "memory";
|
|
|
|
/**
|
|
* Why the active store is the active store. `configured` means the user
|
|
* named it via `MCP_INSPECTOR_SECRET_STORE`; `default` means the keychain
|
|
* was reachable and we used it; `fallback` means the keychain was *not*
|
|
* reachable and we picked something that works instead — the case that
|
|
* earns a loud banner, because it is the one where the answer to "where
|
|
* did my secret go" changed without the user asking for it.
|
|
*/
|
|
export type SecretStoreReason = "configured" | "default" | "fallback";
|
|
|
|
export interface SecretStorageInfo {
|
|
kind: SecretStoreKind;
|
|
reason: SecretStoreReason;
|
|
/**
|
|
* Absolute path of the secrets file, for `kind: "file"` only. Shown in
|
|
* the UI and the banner: a file-backed store whose location is a
|
|
* mystery is barely better than no answer at all.
|
|
*/
|
|
path?: string;
|
|
/**
|
|
* True when a file-backed store is writing secrets in the clear (no
|
|
* `MCP_INSPECTOR_SECRET_KEY`). Always false for the other kinds — a
|
|
* keychain is opaque to us and RAM is not "at rest" in the sense this
|
|
* flag is about.
|
|
*/
|
|
plaintext?: boolean;
|
|
/**
|
|
* True when secrets outlive the process. False only for `memory`, and
|
|
* it is the *promise* being made rather than an implementation detail:
|
|
* an in-memory store is honest precisely because it never claims
|
|
* durability it cannot deliver in a container's writable layer.
|
|
*/
|
|
durable: boolean;
|
|
/**
|
|
* Why the keychain was unreachable, when `reason === "fallback"`. Free
|
|
* text from the underlying loader/binding — surfaced rather than
|
|
* summarized, because the three realistic causes (missing libsecret, no
|
|
* platform binary, a packaging mismatch) need different fixes and only
|
|
* the raw message distinguishes them.
|
|
*/
|
|
detail?: string;
|
|
}
|
|
|
|
/** Short label naming the store — the footer's primary text. */
|
|
export function secretStorageLabel(info: SecretStorageInfo): string {
|
|
switch (info.kind) {
|
|
case "keyring":
|
|
return "OS keychain";
|
|
case "file":
|
|
return info.plaintext ? "File (unencrypted)" : "File (encrypted)";
|
|
case "memory":
|
|
return "Memory (this session only)";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How loudly to say it. `warn` is reserved for the two states a user
|
|
* would want to know about before typing a secret — it is on disk in the
|
|
* clear, or it is about to be lost on exit — so that the ordinary
|
|
* keychain and encrypted-file cases stay quiet and the warning keeps its
|
|
* meaning.
|
|
*/
|
|
export type SecretStorageTone = "neutral" | "warn";
|
|
|
|
export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone {
|
|
if (info.kind === "memory") return "warn";
|
|
if (info.plaintext) return "warn";
|
|
return "neutral";
|
|
}
|
|
|
|
/**
|
|
* One sentence of consequence, or undefined when there is none worth
|
|
* saying. Consequence, not mechanism: "will be lost when the Inspector
|
|
* exits" is what a user can act on; "uses an in-memory Map" is not.
|
|
*/
|
|
export function secretStorageCaveat(
|
|
info: SecretStorageInfo,
|
|
): string | undefined {
|
|
if (info.kind === "memory") {
|
|
return "Secrets are not written anywhere and are lost when the Inspector exits.";
|
|
}
|
|
if (info.plaintext) {
|
|
return "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them.";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* The full sentence, for the startup banner and any single-line surface.
|
|
* Built from the same three helpers the UI uses so the terminal and the
|
|
* browser cannot drift into describing the same store differently.
|
|
*/
|
|
export function secretStorageSummary(info: SecretStorageInfo): string {
|
|
const parts = [secretStorageLabel(info)];
|
|
if (info.path) parts.push(`at ${info.path}`);
|
|
if (info.reason === "fallback") {
|
|
parts.push("— fell back from the OS keychain");
|
|
}
|
|
return parts.join(" ");
|
|
}
|