inspector/core/react/useInitialConfig.ts
cliffhall de84b67046 fix(auth): stop blaming the passphrase for a corrupt payload, and refresh the stale docs
Round 23 produced **no inline comments** — nothing wrong in the code it had
not already seen. The five suppressed ones are all in unchanged code: one real
misdiagnosis and four pieces of drift.

**A decrypted-but-corrupt payload was reported as a wrong passphrase.** By the
time `JSON.parse` runs, GCM has verified the tag — so the passphrase is
*proven correct*, and a `SecretFileKeyMismatchError` there tells the user to
restore a secret they never lost. `asSecretMap` was already outside that catch
for exactly this reason; `JSON.parse` was not, so the same class of mistake
survived one line above the fix for it. The decrypt now has its own narrow
catch and everything after it reports a corrupt file.

The test fixture could not express this case — it `JSON.stringify`s its
payload, so it can only produce valid JSON. Split into a raw-plaintext variant
so a fixture can carry bytes that are authentic under the passphrase and still
unparseable, which is the only shape that reaches the branch.

The rest is documentation that had drifted from the code it describes:

- `handOff`'s doc still described the re-read-and-compare protocol that the
  atomic claim replaced two rounds ago — an obsolete concurrency invariant is
  worse than none, because the next reader takes it as the contract.
- The spec still described that same hand-off, and still called Client and
  Server Settings "the only two dialogs that accept a secret". Both now match
  the code, and the dialog count is called out as load-bearing: the one that
  was missing from it was the one taking secrets with no disclosure at all.
- The `void load()` in `refresh` carries the justification the repo's
  no-floating-promises rule requires — `load` owns its failures, and `refresh`
  is deliberately synchronous because `refreshingPersist` calls it from a
  `finally`.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 23:08:52 -04:00

267 lines
12 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, useRef, 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;
/**
* Re-fetch the payload.
*
* Exists for `secretStorage`, which is the one field here that describes
* state *this app changes*. Saving a secret under a newly-set passphrase
* re-encrypts the file, so the descriptor the backend would serve now
* differs from the one fetched at mount — and without a way to ask again,
* the footer keeps saying "Plaintext file" for the rest of the session,
* about a file that is no longer plaintext. The backend already re-derives
* the descriptor per request; this is the client half of that.
*
* Safe to call at any time and safe to overlap: the GET is idempotent, and
* loads are ordered by a request token *and* gated on the hook still being
* mounted — so a response arriving after unmount, after a re-fetch, or out
* of order behind a newer refresh is dropped. Calling it after unmount is a
* no-op rather than a state update, which matters because the callers fire
* it from an async persist that can outlive the tree.
*/
refresh: () => void;
}
/** 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;
// Exactly one of the two must be present: either we know the encryption
// state, or we know we could not read it. A descriptor carrying neither
// (or both) was not built by a backend we understand, and the footer
// would have to invent the missing half.
const knowsEncryption = typeof v.plaintext === "boolean";
const knowsItCannotRead = typeof v.encryptionUnknown === "string";
if (knowsEncryption === knowsItCannotRead) return undefined;
if (v.encryptionUnknown !== undefined && !knowsItCannotRead) {
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;
}
if (
v.permissionsUnknown !== undefined &&
typeof v.permissionsUnknown !== "string"
) {
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 ||
v.permissionsUnknown !== undefined ||
v.encryptionUnknown !== 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);
/**
* Monotonic request token. Every load claims the next value; a load may
* commit only while it still holds the current one.
*
* This replaces a per-caller "am I cancelled" flag, which was enough for
* the mount fetch and *not* enough once `refresh()` existed. Two overlapping
* refreshes — two debounced settings saves landing close together — are
* unordered, so the earlier request could resolve last and put the footer
* back to the descriptor it had before the write. Stale-response ordering
* matters more here than in most places precisely because the field it
* would revert is a security statement.
*
* Bumping on effect teardown also covers a `baseUrl`/`authToken` change
* that re-runs the effect, by invalidating whatever that run started.
*
* It does **not** cover unmount on its own, and an earlier version of this
* comment claimed it did. The teardown bump only invalidates loads already
* in flight; a `refresh()` called *after* unmount — which happens when an
* async settings persist resolves after `App` has gone — claims the next
* token, is therefore current, and commits into a dead hook. Mounted state
* is tracked separately for that, and `stale()` checks both.
*/
const generation = useRef(0);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const load = useCallback(async (): Promise<void> => {
const mine = ++generation.current;
const stale = () => !mounted.current || generation.current !== mine;
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 (stale() || !res.ok) return;
const body = (await res.json()) as ConfigPayload;
if (stale()) 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 (!stale()) setLoading(false);
}
}, [base, authToken, doFetch]);
useEffect(() => {
void load();
return () => {
// Invalidate whatever is in flight: this fires on unmount and on a
// `baseUrl`/`authToken` change, and in both cases a response for the
// old inputs must not commit.
generation.current++;
};
}, [load]);
const refresh = useCallback(() => {
// `void` because `load` owns its failures — every fetch/parse error is
// caught inside it and leaves the fields at their defaults — and because
// `refresh` is deliberately synchronous: its callers fire it from
// `refreshingPersist`'s `finally`, where a returned promise would be
// awaited by a caller that has nothing to do with the result.
void load();
}, [load]);
return { version, sandboxUrl, writable, secretStorage, loading, refresh };
}