Nine findings (six inline, three suppressed), all taken. Two were serious. **The memory fallback made the migrations destructive.** Both migrations — mcp.json in the remote server, client.json in `node-persistence` — lift a plaintext secret off disk, write it to the secret store, and then delete it from the file. That trade is safe only while the store is at least as durable as the disk, which held by construction while `InMemorySecretStore` was a test double. Making it a production fallback broke it: on an unmounted container a plain `GET /api/servers` would have moved a user's existing secrets into RAM and lost them at exit — destroying data on a read. `SecretStore` gains an optional `isDurable()`, absent meaning durable, and both migrations withhold the disk delete when it answers false (the value is still loaded, so the session behaves normally). The production fallback is now a named `SessionSecretStore` rather than the bare `InMemorySecretStore`, so the suite's many doubles keep standing in for a *working keychain* — which is what they are there to be — and only code that deliberately chooses RAM in production says so. **Round 2's per-call descriptor never reached anyone.** Both web servers resolved it once at startup and baked it into `initialConfig`, which `/api/config` serves unchanged — so the freshness fix was inert for its only consumer. The route now takes a `secretStorageResolver` and calls it per request. The lock needed three more fixes, and they compound: - **Release removed whatever directory was at the path.** A holder whose work outran the stale threshold would delete the *next* owner's lock, letting a third writer into a section two processes believed they held. Ownership is now stamped with a random token and release only removes a lock still carrying it. - **The timeout fell through to an unlocked write** — under contention, the one moment another writer is provably active. It now throws `SecretFileLockTimeoutError`, which `set` is allowed to do. - **The first write of every process bypassed the lock**, because the secrets directory does not exist yet and `mkdir` failed ENOENT. The parent is created first. First writes are exactly when two processes are most likely to start together. Fixing the timeout exposed a fourth: with stale (10s) below timeout, a waiter stole from a holder that was merely *slow* rather than dead — reinstating the lost update. A holder now heartbeats its lock, so "stale" means the holder is gone, and the timeout is what a live-but-slow holder produces. Lock timings are injectable so the tests can exercise contention, staleness, and timeout without spending the real intervals. Also: the loose-permission state is carried into `SecretStorageInfo` as `looseMode` and rendered as the caveat (it outranks the encryption caveat — a file others can read is a live exposure, while "unencrypted" is a property of a file only its owner can open), rather than living in a startup log the browser cannot see; and `usableSecretStorage` validates `pendingEncryption` and `looseMode` as the optional booleans/numbers they are (`pendingEncryption: "false"` is truthy and would have printed advice that is the opposite of the truth) and rejects file-only fields on a keychain or memory descriptor. Tests: both migrations against a session store with a durable control alongside, so the guard is visibly the difference; `/api/config` serving a changing resolver and falling back without one; lock ownership after a steal, the timeout under a live holder, no-steal from a slow holder, and a locked first write; `looseMode` through the descriptor, the caveat ordering, and the tone; and five more rejected payload shapes. Full `npm run ci` green — 5,704 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com>
194 lines
8.2 KiB
TypeScript
194 lines
8.2 KiB
TypeScript
/**
|
|
* React hook over the `GET /api/config` endpoint — the single fetch of the
|
|
* static `InitialConfigPayload` the dev/prod web backends serve alongside the
|
|
* SPA (see `core/mcp/remote/node/server.ts`). It exposes the three fields the
|
|
* web app reads off that payload:
|
|
*
|
|
* - `sandboxUrl` — the MCP Apps sandbox proxy URL. The backend mounts
|
|
* `sandbox_proxy.html` on a separate controller port and advertises the URL
|
|
* here; the Apps screen embeds it as the trusted outer iframe. `undefined`
|
|
* until the fetch resolves, and whenever the backend omits it (legacy backend,
|
|
* or a build without the sandbox controller) — callers treat that as "Apps
|
|
* unavailable" rather than a blank iframe.
|
|
* - `writable` — whether the session's server list is a writable catalog or a
|
|
* read-only session (`--config` / ad-hoc `--server-url`). Defaults to `true`
|
|
* until the fetch resolves and whenever the field is absent (a legacy backend
|
|
* predating the flag), so the default catalog keeps full CRUD.
|
|
* - `version` — the Inspector version the backend reads from the root
|
|
* `package.json` (the browser can't read it off disk). `undefined` until the
|
|
* fetch resolves, and whenever the backend omits it (legacy backend) — the UI
|
|
* renders nothing then.
|
|
* - `secretStorage` — which store this session's secrets go to (#1950),
|
|
* resolved on the Node side where the keychain probe happens. `undefined`
|
|
* until the fetch resolves and on a backend that omits it; the settings-modal
|
|
* footers render nothing then, because a guessed answer under a secret field
|
|
* is worse than no answer.
|
|
*
|
|
* This consolidates the three former single-field hooks (`useSandboxUrl`,
|
|
* `useServerListWritable`, `useInspectorVersion`), each of which fetched the
|
|
* same static payload separately, into one request (#1643).
|
|
*
|
|
* Fetches on mount, and re-fetches if `baseUrl` or `authToken` changes (rare —
|
|
* effectively a full reload; the GET is idempotent). A response that resolves
|
|
* after unmount or a re-fetch is dropped rather than overwriting current state.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import type { SecretStorageInfo } from "../auth/secret-storage-info.js";
|
|
|
|
export interface UseInitialConfigOptions {
|
|
/** Base URL of the remote server (typically `window.location.origin`). */
|
|
baseUrl: string;
|
|
/** Optional auth token for the `x-mcp-remote-auth` header. */
|
|
authToken?: string;
|
|
/** Fetch function to use (default: globalThis.fetch). Useful in tests. */
|
|
fetchFn?: typeof fetch;
|
|
}
|
|
|
|
export interface UseInitialConfigResult {
|
|
/** The Inspector version, or undefined when unavailable / not yet loaded. */
|
|
version: string | undefined;
|
|
/** The sandbox proxy URL, or undefined when unavailable / not yet loaded. */
|
|
sandboxUrl: string | undefined;
|
|
/** Whether the server list is writable (catalog) or read-only (session). */
|
|
writable: boolean;
|
|
/** Where this session's secrets are stored, or undefined when unknown. */
|
|
secretStorage: SecretStorageInfo | undefined;
|
|
/** True while the initial fetch is in flight. */
|
|
loading: boolean;
|
|
}
|
|
|
|
/** Minimal shape we read from the `/api/config` payload. */
|
|
interface ConfigPayload {
|
|
version?: unknown;
|
|
sandboxUrl?: unknown;
|
|
writable?: unknown;
|
|
secretStorage?: unknown;
|
|
}
|
|
|
|
/**
|
|
* Narrow the payload's `secretStorage` before trusting it.
|
|
*
|
|
* The footer states, in the UI, where a user's secret is about to go, so a
|
|
* malformed or partial descriptor must render as "unknown" rather than as a
|
|
* confident half-answer.
|
|
*
|
|
* Every field the footer reads is checked, not just `kind`. A partial
|
|
* `{ kind: "file" }` would otherwise pass, and because a missing `plaintext`
|
|
* is falsy it would render as the quiet, neutral *encrypted* file — the
|
|
* single most misleading thing this component can say, produced by the
|
|
* absence of information rather than by any claim the backend made. Each
|
|
* kind's own required fields are checked for the same reason: a file store
|
|
* with no `path` cannot answer the question a file store exists to answer.
|
|
*/
|
|
function usableSecretStorage(value: unknown): SecretStorageInfo | undefined {
|
|
if (typeof value !== "object" || value === null) return undefined;
|
|
const v = value as Record<string, unknown>;
|
|
if (v.kind !== "keyring" && v.kind !== "file" && v.kind !== "memory") {
|
|
return undefined;
|
|
}
|
|
if (
|
|
v.reason !== "configured" &&
|
|
v.reason !== "default" &&
|
|
v.reason !== "fallback"
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (typeof v.durable !== "boolean") return undefined;
|
|
if (v.kind === "file") {
|
|
if (typeof v.path !== "string" || v.path === "") return undefined;
|
|
if (typeof v.plaintext !== "boolean") return undefined;
|
|
// Optional, so absent is fine — but a *present* value must be a real
|
|
// boolean. `pendingEncryption: "false"` is a truthy string, and the
|
|
// footer would read it as "already re-encrypting" and print advice that
|
|
// is the opposite of the truth.
|
|
if (
|
|
v.pendingEncryption !== undefined &&
|
|
typeof v.pendingEncryption !== "boolean"
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (v.looseMode !== undefined && typeof v.looseMode !== "number") {
|
|
return undefined;
|
|
}
|
|
} else if (
|
|
// File-only fields on a keychain or memory descriptor mean the payload
|
|
// was not built by a backend we understand. Rejecting is cheap and the
|
|
// alternative is rendering a mixture of two stores' answers.
|
|
v.path !== undefined ||
|
|
v.plaintext !== undefined ||
|
|
v.pendingEncryption !== undefined ||
|
|
v.looseMode !== undefined
|
|
) {
|
|
return undefined;
|
|
}
|
|
return value as SecretStorageInfo;
|
|
}
|
|
|
|
/** Coerce a payload field to a usable non-empty string, else undefined. */
|
|
function usableString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value ? value : undefined;
|
|
}
|
|
|
|
export function useInitialConfig(
|
|
opts: UseInitialConfigOptions,
|
|
): UseInitialConfigResult {
|
|
const { baseUrl, authToken, fetchFn } = opts;
|
|
const doFetch = fetchFn ?? globalThis.fetch;
|
|
const base = baseUrl.replace(/\/$/, "");
|
|
|
|
const [version, setVersion] = useState<string | undefined>(undefined);
|
|
const [sandboxUrl, setSandboxUrl] = useState<string | undefined>(undefined);
|
|
// Default writable so the common (catalog) case shows CRUD immediately and a
|
|
// legacy backend that omits the field keeps working.
|
|
const [writable, setWritable] = useState<boolean>(true);
|
|
const [secretStorage, setSecretStorage] = useState<
|
|
SecretStorageInfo | undefined
|
|
>(undefined);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
|
|
// `isCancelled` lets the effect drop a response that resolves after unmount or
|
|
// after a re-run (baseUrl/authToken change), so a stale payload can't overwrite
|
|
// current state. React 18 no longer warns on setState-after-unmount — it's a
|
|
// silent no-op — so the guard is about correctness, not the warning.
|
|
const load = useCallback(
|
|
async (isCancelled: () => boolean): Promise<void> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`;
|
|
try {
|
|
const res = await doFetch(`${base}/api/config`, {
|
|
method: "GET",
|
|
headers,
|
|
});
|
|
if (isCancelled() || !res.ok) return;
|
|
const body = (await res.json()) as ConfigPayload;
|
|
if (isCancelled()) return;
|
|
// Tolerate missing/non-usable fields — a legacy backend that omits any
|
|
// of them leaves that value at its "unavailable" default rather than
|
|
// showing a bogus value.
|
|
setVersion(usableString(body.version));
|
|
setSandboxUrl(usableString(body.sandboxUrl));
|
|
// Only an explicit `false` makes the list read-only; a missing field
|
|
// (legacy backend) stays writable.
|
|
setWritable(body.writable !== false);
|
|
setSecretStorage(usableSecretStorage(body.secretStorage));
|
|
} catch {
|
|
// Network error / aborted fetch: leave every field at its default
|
|
// (version/sandboxUrl undefined, writable true).
|
|
} finally {
|
|
if (!isCancelled()) setLoading(false);
|
|
}
|
|
},
|
|
[base, authToken, doFetch],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void load(() => cancelled);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [load]);
|
|
|
|
return { version, sandboxUrl, writable, secretStorage, loading };
|
|
}
|