style(web): tighten the in-memory caveat and punctuate the footer
Reads "Memory (this session only): Secrets are not written anywhere and are lost on exit." — shorter tail than "lost when the Inspector exits", and a colon separating the store from what follows it. The colon is added at the footer's composition, not inside `secretStorageLabel`. The label is also consumed alone by `secretStorageSummary`, which appends " at <path>" for a file-backed store, so a colon baked into the label would print "File (encrypted): at /path" in the startup banner. It renders only when a detail actually follows. The caveat string is shared with the banner's warning line, so the terminal picks up the shorter wording too. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0cb59cf3fd
commit
2d2c787e22
6 changed files with 123 additions and 14 deletions
|
|
@ -80,10 +80,10 @@ export const InMemory: Story = {
|
|||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const band = await canvas.findByTestId("secret-storage-footer");
|
||||
await expect(band).toHaveTextContent("Secrets: Memory (this session only)");
|
||||
await expect(
|
||||
canvas.getByText(/lost when the Inspector exits/),
|
||||
).toBeInTheDocument();
|
||||
await expect(band).toHaveTextContent(
|
||||
"Secrets: Memory (this session only):",
|
||||
);
|
||||
await expect(canvas.getByText(/lost on exit/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -62,10 +62,8 @@ describe("SecretStorageFooter", () => {
|
|||
it("warns that an in-memory store loses secrets on exit", () => {
|
||||
renderWithMantine(<SecretStorageFooter info={memory} />);
|
||||
const band = screen.getByTestId("secret-storage-footer");
|
||||
expect(band).toHaveTextContent("Secrets: Memory (this session only)");
|
||||
expect(
|
||||
screen.getByText(/lost when the Inspector exits/),
|
||||
).toBeInTheDocument();
|
||||
expect(band).toHaveTextContent("Secrets: Memory (this session only):");
|
||||
expect(screen.getByText(/lost on exit/)).toBeInTheDocument();
|
||||
expect(band).toHaveAttribute("data-tone", "warn");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -144,9 +144,14 @@ export function SecretStorageFooter({ info }: SecretStorageFooterProps) {
|
|||
`.withProps()` rule, and it takes `color` as a prop rather than a
|
||||
style. */}
|
||||
<Icon size={ICON_SIZE} color={color} aria-hidden />
|
||||
{/* The colon lives here rather than in `secretStorageLabel`, because the
|
||||
label is also used on its own — by `secretStorageSummary` for the
|
||||
startup banner, where a trailing colon before " at <path>" would be
|
||||
wrong. It appears only when something follows it. */}
|
||||
<FooterLabel c={color}>
|
||||
<FooterLabelPrefix>Secrets:</FooterLabelPrefix>{" "}
|
||||
{secretStorageLabel(info)}
|
||||
{detail ? ":" : ""}
|
||||
</FooterLabel>
|
||||
{detail && <FooterDetail c={color}>{detail}</FooterDetail>}
|
||||
</FooterBand>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ describe("secretStorageCaveat", () => {
|
|||
|
||||
it("names the loss for memory", () => {
|
||||
expect(secretStorageCaveat(memory)).toContain(
|
||||
"lost when the Inspector exits",
|
||||
"lost on exit",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ describe("secretStorageCaveat", () => {
|
|||
// 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",
|
||||
"lost on exit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
* which is what makes them worth exporting.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { existsSync } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
|
@ -27,6 +28,10 @@ import {
|
|||
parseSecretStoreEnv,
|
||||
warnAboutSecretStorage,
|
||||
} from "@inspector/core/auth/node/secret-store-selection.js";
|
||||
import {
|
||||
InMemorySecretStore,
|
||||
type SecretStore,
|
||||
} from "@inspector/core/auth/node/secret-store.js";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
|
|
@ -384,7 +389,7 @@ describe("warnAboutSecretStorage", () => {
|
|||
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");
|
||||
expect(output).toContain("lost on exit");
|
||||
});
|
||||
|
||||
it("warns about an unencrypted file even when it was explicitly chosen", () => {
|
||||
|
|
@ -540,6 +545,107 @@ describe("defaultSecretFilePath", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("absorbFileSecretsIntoKeyring", () => {
|
||||
// The transition: a box without libsecret falls back to a file, the user
|
||||
// saves secrets there, then installs libsecret. Without a hand-off the next
|
||||
// run selects the keychain and every saved value silently disappears —
|
||||
// still on disk, but read by nothing.
|
||||
async function seedFile(secrets: Record<string, string>): Promise<string> {
|
||||
const filePath = path.join(tmpDir, "secrets.json");
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ version: 1, encryption: "none", secrets }),
|
||||
"utf-8",
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
it("moves the file's secrets into the keychain and removes the file", async () => {
|
||||
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const keyring = new InMemorySecretStore();
|
||||
|
||||
await mod.absorbFileSecretsIntoKeyring(keyring);
|
||||
|
||||
expect(await keyring.get("srv", "oauthClientSecret")).toBe("from-file");
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the keychain win on conflict", async () => {
|
||||
// The file's copy is the older one by construction — it was written while
|
||||
// the keychain was unreachable.
|
||||
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const keyring = new InMemorySecretStore();
|
||||
await keyring.set("srv", "oauthClientSecret", "already-here");
|
||||
|
||||
await mod.absorbFileSecretsIntoKeyring(keyring);
|
||||
|
||||
expect(await keyring.get("srv", "oauthClientSecret")).toBe("already-here");
|
||||
});
|
||||
|
||||
it("leaves the file alone when the keychain write fails", async () => {
|
||||
// A partial hand-off must not delete the source: the next run retries and
|
||||
// the keychain-wins rule absorbs whatever already made it across.
|
||||
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const failing: SecretStore = {
|
||||
get: async () => null,
|
||||
set: async () => {
|
||||
throw new Error("keychain exploded");
|
||||
},
|
||||
delete: async () => {},
|
||||
deleteAllForServer: async () => {},
|
||||
};
|
||||
|
||||
await mod.absorbFileSecretsIntoKeyring(failing);
|
||||
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("left in place"));
|
||||
});
|
||||
|
||||
it("does not delete a file it cannot read", async () => {
|
||||
// An undecryptable file is not an empty one. Deleting it here would lose
|
||||
// exactly the values this whole hand-off exists to preserve.
|
||||
const filePath = path.join(tmpDir, "secrets.json");
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
process.env.MCP_INSPECTOR_SECRET_KEY = "right-key";
|
||||
const seeded = await loadWithProbe(false);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await new (
|
||||
await import("@inspector/core/auth/node/file-secret-store.js")
|
||||
).FileSecretStore({ filePath, passphrase: "right-key" }).set(
|
||||
"srv",
|
||||
"oauthClientSecret",
|
||||
"encrypted",
|
||||
);
|
||||
void seeded;
|
||||
|
||||
delete process.env.MCP_INSPECTOR_SECRET_KEY;
|
||||
const mod = await loadWithProbe(true);
|
||||
const keyring = new InMemorySecretStore();
|
||||
|
||||
await mod.absorbFileSecretsIntoKeyring(keyring);
|
||||
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(await keyring.get("srv", "oauthClientSecret")).toBe(null);
|
||||
});
|
||||
|
||||
it("is a no-op when there is no file", async () => {
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "absent.json");
|
||||
const mod = await loadWithProbe(true);
|
||||
const keyring = new InMemorySecretStore();
|
||||
await mod.absorbFileSecretsIntoKeyring(keyring);
|
||||
expect(existsSync(path.join(tmpDir, "absent.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the descriptor reports a mode it could not tighten", () => {
|
||||
it("carries looseMode into the descriptor and warns once at selection", async () => {
|
||||
// The warning alone was not enough: the caveat under the secret field
|
||||
|
|
|
|||
|
|
@ -121,14 +121,14 @@ export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone {
|
|||
|
||||
/**
|
||||
* 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.
|
||||
* saying. Consequence, not mechanism: "lost on exit" 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.";
|
||||
return "Secrets are not written anywhere and are lost on exit.";
|
||||
}
|
||||
// The permission problem outranks the encryption one when both hold: a
|
||||
// file other users can read is a live exposure, while "unencrypted" is a
|
||||
|
|
|
|||
Loading…
Reference in a new issue