diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2d45a0dd..9eae7f4e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### πŸ”§ Bug Fixes +- **fix(dashboard):** Agent Bridge page (`/dashboard/tools/agent-bridge`) no longer crashes with "Internal Server Error" β€” the page replaced its well-shaped state with the raw `/api/tools/agent-bridge/state` response (`{ server, agents }`), leaving `serverState` undefined and throwing `Cannot read properties of undefined (reading 'running')`. A shared `normalizeAgentBridgeState()` now maps the route shape into the page contract (incl. `server.certExists β†’ certTrusted`) and always returns safe defaults, used by both the SSR loader and the polling hook. (#3318 β€” thanks @tycronk20) - **fix(codex):** strip client-only params (`prompt_cache_retention`, `safety_identifier`, `user`) on the native `codex/` `/v1/responses` passthrough β€” Codex upstream rejects them with `400 Unsupported parameter`, which broke Factory Droid and any client injecting those fields. The chat-completions path already stripped them; the responsesβ†’responses passthrough now does too. (#3317 β€” thanks @tycronk20) - **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path β€” the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 β€” thanks @onizukashonan14-png) - **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter β€” they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 β€” thanks @uniQta) diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/hooks/useAgentBridgeState.ts b/src/app/(dashboard)/dashboard/tools/agent-bridge/hooks/useAgentBridgeState.ts index 02b9074b0e..630cac8344 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/hooks/useAgentBridgeState.ts +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/hooks/useAgentBridgeState.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { AgentBridgePageData } from "../AgentBridgePageClient"; +import { normalizeAgentBridgeState } from "../normalizeState"; interface UseAgentBridgeStateOptions { initialData: AgentBridgePageData; @@ -39,7 +40,9 @@ export function useAgentBridgeState({ signal: ctrl.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = (await res.json()) as AgentBridgePageData; + // #3318: normalize the /state response so a key mismatch can't leave + // serverState undefined and crash the page on the next render. + const json = normalizeAgentBridgeState(await res.json()); if (!ctrl.signal.aborted) setData(json); } catch (err) { if (!ctrl.signal.aborted) { diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/normalizeState.ts b/src/app/(dashboard)/dashboard/tools/agent-bridge/normalizeState.ts new file mode 100644 index 0000000000..43763f9aa8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/normalizeState.ts @@ -0,0 +1,76 @@ +import type { + AgentBridgePageData, + AgentBridgeServerState, + AgentStateEntry, + AgentMappingsMap, +} from "./AgentBridgePageClient"; + +function defaultServerState(): AgentBridgeServerState { + return { + running: false, + port: 443, + certTrusted: false, + upstreamCa: null, + lastStartedAt: null, + activeConns: 0, + interceptedCount: 0, + }; +} + +export const DEFAULT_AGENT_BRIDGE_STATE: AgentBridgePageData = { + serverState: defaultServerState(), + agentStates: [], + bypassPatterns: [], + mappings: {}, +}; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** + * Normalize whatever `/api/tools/agent-bridge/state` returns into the shape the + * page/components require, ALWAYS returning a well-formed object (#3318). + * + * The page previously assigned the raw response straight into `initialData`, but + * the route returns `{ server, agents }` while the UI reads + * `{ serverState, agentStates, bypassPatterns, mappings }` β€” leaving + * `serverState` undefined and crashing `serverState.running`. + * + * This maps the known server fields through (incl. the legacy `server` key β†’ + * `serverState`, `server.certExists` β†’ `certTrusted`) and falls back to safe + * defaults for everything else, so the page renders instead of crashing. + * Note: the legacy `agents` payload has a different per-entry shape than + * `AgentStateEntry`, so it is intentionally NOT coerced β€” `agentStates` defaults + * to `[]` unless the response already provides the correct `agentStates` key. + */ +export function normalizeAgentBridgeState(raw: unknown): AgentBridgePageData { + if (!isRecord(raw)) return { ...DEFAULT_AGENT_BRIDGE_STATE, serverState: defaultServerState() }; + + const serverState = defaultServerState(); + const source = isRecord(raw.serverState) + ? raw.serverState + : isRecord(raw.server) + ? raw.server + : undefined; + if (source) { + if (typeof source.running === "boolean") serverState.running = source.running; + if (typeof source.port === "number") serverState.port = source.port; + // accept both the canonical `certTrusted` and the legacy `certExists` + if (typeof source.certTrusted === "boolean") serverState.certTrusted = source.certTrusted; + else if (typeof source.certExists === "boolean") serverState.certTrusted = source.certExists; + if (typeof source.upstreamCa === "string") serverState.upstreamCa = source.upstreamCa; + if (typeof source.lastStartedAt === "string") serverState.lastStartedAt = source.lastStartedAt; + if (typeof source.activeConns === "number") serverState.activeConns = source.activeConns; + if (typeof source.interceptedCount === "number") { + serverState.interceptedCount = source.interceptedCount; + } + } + + return { + serverState, + agentStates: Array.isArray(raw.agentStates) ? (raw.agentStates as AgentStateEntry[]) : [], + bypassPatterns: Array.isArray(raw.bypassPatterns) ? (raw.bypassPatterns as string[]) : [], + mappings: isRecord(raw.mappings) ? (raw.mappings as AgentMappingsMap) : {}, + }; +} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx index f72049d483..9ce144cc7d 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx @@ -2,6 +2,7 @@ import { getProviderConnections } from "@/lib/db/providers"; import { ALL_TARGETS } from "@/mitm/targets/index"; import AgentBridgePageClient from "./AgentBridgePageClient"; import type { AgentBridgePageData } from "./AgentBridgePageClient"; +import { normalizeAgentBridgeState } from "./normalizeState"; /** * AgentBridge page β€” Server Component entry point. @@ -44,8 +45,10 @@ export default async function AgentBridgePage() { headers: { "x-internal-fetch": "1" }, }); if (res.ok) { - const json = (await res.json()) as AgentBridgePageData; - initialData = json; + // #3318: normalize β€” the /state route returns `{ server, agents }`, not the + // page's `{ serverState, ... }` shape; replacing initialData verbatim left + // serverState undefined and crashed the page. + initialData = normalizeAgentBridgeState(await res.json()); } } catch { // Backend not yet available β€” use defaults; client will poll diff --git a/tests/unit/agent-bridge-state-normalize-3318.test.ts b/tests/unit/agent-bridge-state-normalize-3318.test.ts new file mode 100644 index 0000000000..2396d75fc5 --- /dev/null +++ b/tests/unit/agent-bridge-state-normalize-3318.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { normalizeAgentBridgeState, DEFAULT_AGENT_BRIDGE_STATE } = await import( + "../../src/app/(dashboard)/dashboard/tools/agent-bridge/normalizeState.ts" +); + +// #3318: the /api/tools/agent-bridge/state route returns `{ server, agents }`, +// but the page/components read `{ serverState, agentStates, bypassPatterns, +// mappings }`. The page replaced its well-shaped default with the raw response, +// so `serverState` became undefined β†’ `serverState.running` crashed the page +// with the full "Internal Server Error" boundary. The normalizer must always +// return a well-shaped object (never an undefined serverState), mapping the +// known server fields through. + +test("the raw /state route shape lacks the keys the page reads (documents the bug)", () => { + const routeShape = { + server: { running: true, pid: 123, dnsConfigured: true, certExists: true }, + agents: [{ id: "claude-code", name: "Claude Code", hosts: [], viability: "ok" }], + }; + // This is exactly what the old code assigned straight into initialData. + assert.equal(routeShape.serverState, undefined); +}); + +test("normalizeAgentBridgeState maps the route shape and never leaves serverState undefined (#3318)", () => { + const routeShape = { + server: { running: true, pid: 123, dnsConfigured: true, certExists: true }, + agents: [{ id: "claude-code", name: "Claude Code", hosts: [], viability: "ok" }], + }; + const result = normalizeAgentBridgeState(routeShape); + + assert.ok(result.serverState, "serverState must be defined"); + assert.equal(result.serverState.running, true, "server.running maps through"); + assert.equal(result.serverState.certTrusted, true, "server.certExists -> certTrusted"); + assert.ok(Array.isArray(result.agentStates), "agentStates is always an array"); + assert.ok(Array.isArray(result.bypassPatterns), "bypassPatterns is always an array"); + assert.equal(typeof result.mappings, "object", "mappings is always an object"); +}); + +test("normalizeAgentBridgeState falls back to safe defaults for empty/garbage input", () => { + for (const bad of [null, undefined, {}, 42, "x", []]) { + const result = normalizeAgentBridgeState(bad); + assert.ok(result.serverState, `serverState defined for ${JSON.stringify(bad)}`); + assert.equal(result.serverState.running, false); + assert.ok(Array.isArray(result.agentStates)); + assert.ok(Array.isArray(result.bypassPatterns)); + assert.equal(typeof result.mappings, "object"); + } +}); + +test("normalizeAgentBridgeState passes a correctly-shaped payload through intact", () => { + const correct = { + ...DEFAULT_AGENT_BRIDGE_STATE, + serverState: { ...DEFAULT_AGENT_BRIDGE_STATE.serverState, running: true, port: 8443 }, + agentStates: [ + { + agent_id: "claude-code", + dns_enabled: true, + cert_trusted: true, + setup_completed: true, + last_started_at: null, + last_error: null, + }, + ], + bypassPatterns: ["*.internal"], + mappings: { "claude-code": [] }, + }; + const result = normalizeAgentBridgeState(correct); + assert.equal(result.serverState.running, true); + assert.equal(result.serverState.port, 8443); + assert.equal(result.agentStates.length, 1); + assert.equal(result.agentStates[0].agent_id, "claude-code"); + assert.deepEqual(result.bypassPatterns, ["*.internal"]); +});