* chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory (#1838) eslint 9 pulled `minimatch@3` → `brace-expansion@1.1.16`, which carries GHSA-mh99-v99m-4gvg (high, DoS via unbounded expansion length). It appeared in all five lockfiles — the root plus each of the four clients, none of which is an npm workspace, so all five had to move together. Deferred from the pre-2.0.0 sweep because it is dev-scope, not attacker- controlled, and a major bump across five packages was the wrong thing to attempt immediately before an irreversible publish. This is that work. ## What moved - `eslint` `^9.39.4/5` → `^10.8.0` and `@eslint/js` → `^10.0.1`, in all five manifests. - `eslint-plugin-react-hooks` `^7.0.1` → `^7.1.1` in web and tui. Required, not cosmetic: 7.0.1's peer range stops at `^9.0.0`, so installing it alongside eslint 10 fails `ERESOLVE`. - Everything else already declared eslint 10 support and stayed put: `typescript-eslint@^8.56.1` (`^8.57 || ^9 || ^10`), `eslint-plugin-react-refresh@^0.5.2` (`^9 || ^10`), `eslint-plugin-storybook@^10.2.19` (`>=8`). ## Config migration None was needed. All five configs were already flat (`eslint.config.js` with `defineConfig`/`globalIgnores` from `eslint/config`), which is the format v10 keeps and the only one it still supports. The root config's `core/` and shared- surface blocks — including the `_`-prefix `argsIgnorePattern` / `varsIgnorePattern` / `caughtErrorsIgnorePattern` — are unchanged and still gate `lint:core` and `lint:shared`. No rule was disabled, downgraded, or scoped away to make the new eslint pass. v10 surfaced 21 real violations across three rules; all 21 are fixed in the code. ### `preserve-caught-error` (new in `eslint:recommended`) — 15 sites A `throw new Error(msg)` inside a `catch` silently drops the original error. Each now passes `{ cause }`, so the underlying failure survives into the chain. Messages are unchanged, so nothing that asserts on them moves. 12 in `core/`, 2 in `clients/cli`, 1 in `test-servers/src`. ### `no-useless-assignment` (new in `eslint:recommended`) — 2 sites `clients/cli/src/cli.ts`'s `optionArgs` and `test-servers/src/test-server-oauth` 's `valid` were initialized and then unconditionally overwritten on every path. Both are now declared without the dead initializer; TypeScript's definite- assignment analysis covers the remaining paths. ### `react-hooks/set-state-in-effect` — 8 sites (web) These were false negatives the plugin fixed in 7.1.0, not a rule we newly opted into: `set-state-in-effect` has been `error` in `flat/recommended` since 7.0.1, and the web config has always extended it. Every one was the same anti-pattern — resetting or re-syncing local state from a prop inside a `useEffect`, which paints the stale value for one frame and then renders again to correct it. All eight now use a new `useValueChange(value, onChange)` hook (`clients/web/src/hooks/useValueChange.ts`), which is React's documented "adjusting state during render": compare against the previous render's value with `Object.is` and call back *during* render, so React discards the in-progress output before it reaches the DOM. - `MrtrConversation`, `ProtocolEntry`, `TaskCard` — mirror `isListExpanded`. - `NetworkEntry` — same mirror, plus the "Reveal in Network" force-open. The reveal's rAF scroll stays an effect (real external-system work); only the `setIsExpanded(true)` moved. `isExpanded` is now seeded from `isListExpanded || revealed`, since a render-time sync fires on change and so cannot cover an entry that mounts already revealed. - `PromptArgumentsForm`, `ResourceTemplatePanel` — reset on template/prompt switch. - `ServerConfigModal` — reset on open. Keyed on `opened ? initial : undefined`, which collapses "opened flipped" and "initial changed while open" into one value; `initial` is already memoized, so it cannot thrash. `useValueChange` has its own test file, and `ServerConfigModal` gains an open→edit→close→reopen test (the close leg was previously untested, which is what left the new guard uncovered). ## Advisory delta `npm audit`, per package, before → after: | Package | before | after | | ------------------- | -------------------------- | -------------------------- | | root | 9 (1 low, 6 mod, 2 high) | 8 (1 low, 6 mod, 1 high) | | `clients/web` | 19 (3 low, 6 mod, 6 high, 4 crit) | 17 (3 low, 6 mod, 4 high, 4 crit) | | `clients/cli` | 6 (2 low, 4 high) | 4 (2 low, 2 high) | | `clients/tui` | 4 (1 low, 3 high) | 2 (1 low, 1 high) | | `clients/launcher` | 3 (3 high) | 1 (1 high) | `brace-expansion` is gone from all five — `eslint@10` resolves `minimatch@10` → `brace-expansion@5.0.9`. Dropping `@eslint/eslintrc` (removed in v10) also took `js-yaml` with it, clearing GHSA-52cp-r559-cp3m (high) from all four clients as a bonus. The remainder are unrelated and tracked separately. `npm run ci` passes end to end — validate, the per-file ≥90 coverage gate, the build gate, all five smokes (including `smoke:tui`, run locally on a real TTY), and 462 Storybook tests. Also documents the effect rule in AGENTS.md and mirrors it into `.github/copilot-instructions.md`, since it is now something a reviewer cites. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state useValueChange's referential-stability requirement (#1838) Copilot review on #1900: the hook compares with `Object.is`, so an object or array literal rebuilt in the component body compares unequal every render. Because the callback is what updates state, that is not merely a wasted render — it is an infinite loop. Every current caller passes a primitive (`boolean`, `string`) or an already- memoized value, so nothing changes behaviorally. But the hazard was implicit, and the next caller is the one it would bite. Documented on the hook itself, and one clause added to the AGENTS.md rule with its mirror in `.github/copilot-instructions.md`. No runtime guard: the requirement is the same one a `useEffect` dependency array carries, and here the failure is loud and immediate rather than silent. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): stop asserting the --tool-args-json catch binding is an Error (#1838) Copilot review round 2 on #1900: the `--tool-args-json` parse handler formatted its message with `(e as Error).message`. A `catch` binding is `unknown`, so that cast is an assertion rather than a check — a thrown non-Error would render `--tool-args-json is not valid JSON: undefined`, which points the user at nothing. `JSON.parse` does only throw `SyntaxError` today, so this is not a live bug. But the cast is the kind AGENTS.md asks us not to leave unjustified, the same handler was already being touched here to add `{ cause: e }`, and every other site in this PR uses the guarded form. Now it does too. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: require useValueChange's onChange to be render-pure (#1838) Copilot review round 3 on #1900: `onChange` is called during render, so it is bound by render-phase purity — and the web client mounts under `StrictMode`, which deliberately double-renders in development. A callback doing anything external would run an unpredictable number of times, and concurrent React can abandon an in-progress render entirely. Every current caller already passes a callback that only calls `setState`, so nothing changes. But "runs during render" is the whole point of the hook and the constraint that follows from it was left implicit. Stated on the hook, with the same clause in the AGENTS.md rule and its `.github/copilot-instructions.md` mirror. All three now point at `NetworkEntry` as the worked example of the split: the reveal's force-open is a state update and lives in `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
224 lines
6.6 KiB
TypeScript
224 lines
6.6 KiB
TypeScript
import { UnauthorizedError } from "@modelcontextprotocol/client";
|
|
import type { CallbackParams } from "./types.js";
|
|
import { ZodError } from "zod";
|
|
|
|
type ZodIssueLike = {
|
|
path?: unknown[];
|
|
message?: string;
|
|
code?: string;
|
|
};
|
|
|
|
function isZodIssueArray(value: unknown): value is ZodIssueLike[] {
|
|
return (
|
|
Array.isArray(value) &&
|
|
value.length > 0 &&
|
|
typeof value[0] === "object" &&
|
|
value[0] !== null &&
|
|
"code" in value[0]
|
|
);
|
|
}
|
|
|
|
function formatZodIssues(issues: ZodIssueLike[]): string {
|
|
const tokenResponseIssue = issues.some(
|
|
(issue) =>
|
|
Array.isArray(issue.path) &&
|
|
(issue.path.includes("access_token") ||
|
|
issue.path.includes("token_type")),
|
|
);
|
|
if (tokenResponseIssue) {
|
|
return "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again.";
|
|
}
|
|
return issues
|
|
.map((issue) => {
|
|
const path =
|
|
Array.isArray(issue.path) && issue.path.length
|
|
? issue.path.join(".")
|
|
: "input";
|
|
return `${path}: ${issue.message ?? "invalid"}`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
/**
|
|
* Human-readable detail for OAuth failure toasts/banners (never raw Zod JSON).
|
|
*/
|
|
export function formatOAuthFailureDetail(detail: unknown): string {
|
|
if (detail instanceof ZodError) {
|
|
return formatZodIssues(detail.issues);
|
|
}
|
|
const raw =
|
|
detail instanceof Error
|
|
? detail.message
|
|
: typeof detail === "string"
|
|
? detail
|
|
: String(detail);
|
|
const trimmed = raw.trim();
|
|
if (trimmed.startsWith("[")) {
|
|
try {
|
|
const parsed: unknown = JSON.parse(trimmed);
|
|
if (isZodIssueArray(parsed)) {
|
|
return formatZodIssues(parsed);
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
/**
|
|
* Parse a string as an absolute URL. On failure, throws with `label` and the
|
|
* offending value so callers (and UI toasts) can show what to fix.
|
|
*/
|
|
export function parseHttpUrl(value: string, label: string): URL {
|
|
const trimmed = value.trim();
|
|
try {
|
|
return new URL(trimmed);
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Invalid ${label}: "${trimmed}" (${detail})`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses OAuth 2.1 callback parameters from a URL search string
|
|
* @param location The URL search string (e.g., "?code=abc123" or "?error=access_denied")
|
|
* @returns Parsed callback parameters with success/error information
|
|
*/
|
|
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
|
|
const params = new URLSearchParams(location);
|
|
|
|
const code = params.get("code");
|
|
if (code) {
|
|
const iss = params.get("iss");
|
|
return iss === null
|
|
? { successful: true, code }
|
|
: { successful: true, code, iss };
|
|
}
|
|
|
|
const error = params.get("error");
|
|
const error_description = params.get("error_description");
|
|
const error_uri = params.get("error_uri");
|
|
|
|
if (error) {
|
|
return { successful: false, error, error_description, error_uri };
|
|
}
|
|
|
|
return {
|
|
successful: false,
|
|
error: "invalid_request",
|
|
error_description: "Missing code or error in response",
|
|
error_uri: null,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Generate a random state for the OAuth 2.0 flow.
|
|
* Works in both browser and Node.js environments.
|
|
*
|
|
* @returns A random state for the OAuth 2.0 flow.
|
|
*/
|
|
export const generateOAuthState = (): string => {
|
|
// OAuth state is a CSRF token — it MUST be unpredictable. crypto.getRandomValues
|
|
// is available in every supported runtime (browsers, Node ≥15); if it's somehow
|
|
// missing, fail loudly rather than silently degrading to Math.random (whose
|
|
// output is predictable from a small amount of observed state).
|
|
if (typeof crypto === "undefined" || !crypto.getRandomValues) {
|
|
throw new Error(
|
|
"crypto.getRandomValues is not available; refusing to generate an OAuth state with a non-cryptographic RNG.",
|
|
);
|
|
}
|
|
const array = new Uint8Array(32);
|
|
crypto.getRandomValues(array);
|
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
"",
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Parse OAuth `state` to extract the auth session id (CSRF token).
|
|
* Must be the 64-char hex value from {@link generateOAuthState}.
|
|
*/
|
|
export const parseOAuthState = (state: string): { authId: string } | null => {
|
|
if (!state || typeof state !== "string") return null;
|
|
if (/^[a-f0-9]{64}$/i.test(state)) {
|
|
return { authId: state };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Generates a human-readable error description from OAuth callback error parameters
|
|
* @param params OAuth error callback parameters containing error details
|
|
* @returns Formatted multiline error message with error code, description, and optional URI
|
|
*/
|
|
export const generateOAuthErrorDescription = (
|
|
params: Extract<CallbackParams, { successful: false }>,
|
|
): string => {
|
|
const error = params.error;
|
|
const errorDescription = params.error_description;
|
|
const errorUri = params.error_uri;
|
|
|
|
return [
|
|
`Error: ${error}.`,
|
|
errorDescription ? `Details: ${errorDescription}.` : "",
|
|
errorUri ? `More info: ${errorUri}.` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
};
|
|
|
|
/**
|
|
* True when a thrown connect error represents an upstream 401. The remote
|
|
* transport preserves the status on the error object; as a fallback, match
|
|
* transport wording `"failed …(401)"` so unrelated `(401)` in messages does
|
|
* not trigger OAuth.
|
|
*
|
|
* Under `protocolEra: auto|modern`, a negotiation-probe 401 can surface as
|
|
* `SdkError(EraNegotiationFailed)` with the real {@link UnauthorizedError} at
|
|
* `error.data.cause` (and sometimes native `error.cause`). Walk that chain so
|
|
* OAuth recovery still starts.
|
|
*/
|
|
export function isUnauthorizedError(err: unknown): boolean {
|
|
return isUnauthorizedErrorDeep(err, new Set());
|
|
}
|
|
|
|
function isUnauthorizedErrorDeep(err: unknown, seen: Set<unknown>): boolean {
|
|
if (err == null) return false;
|
|
if (typeof err !== "object") {
|
|
return /\bfailed\b[^\n]*\(401\)/i.test(String(err));
|
|
}
|
|
if (seen.has(err)) return false;
|
|
seen.add(err);
|
|
|
|
if (UnauthorizedError.isInstance(err)) return true;
|
|
|
|
const status = (err as { status?: number }).status;
|
|
const code = (err as { code?: unknown }).code;
|
|
if (status === 401 || code === 401) return true;
|
|
|
|
if (err instanceof Error && /\bfailed\b[^\n]*\(401\)/i.test(err.message)) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
"cause" in err &&
|
|
isUnauthorizedErrorDeep((err as { cause: unknown }).cause, seen)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const data = (err as { data?: unknown }).data;
|
|
if (
|
|
data !== null &&
|
|
typeof data === "object" &&
|
|
"cause" in data &&
|
|
isUnauthorizedErrorDeep((data as { cause: unknown }).cause, seen)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|