diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts new file mode 100644 index 0000000..c7b86c4 --- /dev/null +++ b/apps/web/e2e/core-connection.spec.ts @@ -0,0 +1,454 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +const probeRequestPattern = /\/v1\/agents\?limit=1$/; + +interface ProbeInstrumentationWindow extends Window { + __probeAbortCount?: number; + __probeCallCount?: number; + __resolveFirstProbe?: (() => void) | null; + __stalledProbeCallCount?: number; +} + +async function resetFixture(request: APIRequestContext) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function boot(page: Page, request: APIRequestContext) { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toBeVisible(); +} + +async function openConnection(page: Page) { + const trigger = page.getByRole("button", { name: "Configure Agent Core connection" }); + await trigger.click(); + const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(dialog).toBeVisible(); + return { dialog, trigger }; +} + +test("migrates a stale local token without sending browser authorization when proxy auth is disabled", async ({ + page, + request, +}) => { + await page.addInitScript(() => { + localStorage.setItem("agents-core-web.core-base-url", "/v1"); + sessionStorage.setItem("agents-core-web.core-token", "historical-local-token"); + }); + let browserAuthorizationSeen = false; + page.on("request", (browserRequest) => { + const url = new URL(browserRequest.url()); + if (url.pathname.startsWith("/v1/") && browserRequest.headers().authorization) { + browserAuthorizationSeen = true; + } + }); + await page.route(probeRequestPattern, (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ object: "list", data: [], has_more: false, first_id: null, last_id: null }), + })); + + await boot(page, request); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); + const { dialog } = await openConnection(page); + await expect(dialog).toContainText("Server-managed key not detected"); + await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); + await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); + await dialog.getByRole("button", { name: "Test connection" }).click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + expect(browserAuthorizationSeen).toBe(false); + + await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); + await expect(dialog.getByLabel("Bearer token")).toHaveValue(""); +}); + +test("scrubs a legacy remote HTTP connection before any bearer can leave the page", async ({ page, request }) => { + await page.addInitScript(() => { + localStorage.setItem("agents-core-web.core-base-url", "http://core.example/v1"); + sessionStorage.setItem("agents-core-web.core-token", "legacy-remote-token"); + }); + let unsafeRequests = 0; + await page.route("http://core.example/**", (route) => { + unsafeRequests += 1; + return route.abort("blockedbyclient"); + }); + + await boot(page, request); + + await expect.poll(() => page.evaluate(() => localStorage.getItem("agents-core-web.core-base-url"))).toBe("/v1"); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); + expect(unsafeRequests).toBe(0); + const { dialog } = await openConnection(page); + await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); +}); + +test("rejects an unsafe runtime target before a local proxy write can be attempted", async ({ page, request }) => { + await boot(page, request); + + const outcome = await page.evaluate(async () => { + const modulePath = "/src/lib/connection.ts"; + const { createCore } = await import(/* @vite-ignore */ modulePath); + const originalFetch = window.fetch; + const calls: Array<{ method: string; url: string }> = []; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + method: init?.method ?? "GET", + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + }); + return new Response(JSON.stringify({ id: "must-not-be-created" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + try { + const core = createCore({ + baseUrl: "http://core.example/v1", + token: "must-not-leave-browser", + }); + await core.createAgent({ model: "must-not-create-locally" }); + return { calls, error: null }; + } catch (error) { + return { + calls, + error: error instanceof Error + ? { + code: "code" in error ? String(error.code) : null, + message: error.message, + name: error.name, + } + : null, + }; + } finally { + window.fetch = originalFetch; + } + }); + + expect(outcome.calls).toEqual([]); + expect(outcome.error).toEqual({ + code: "invalid_core_base_url", + message: "The Agent Core base URL is invalid or unsafe.", + name: "InvalidCoreConnectionError", + }); + expect(JSON.stringify(outcome)).not.toContain("must-not-leave-browser"); + expect(JSON.stringify(outcome)).not.toContain("http://core.example/v1"); +}); + +test("switches real connection modes and fences stale probes when the draft changes or reopens", async ({ + page, + request, +}) => { + await page.addInitScript(() => { + const target = window as ProbeInstrumentationWindow; + const originalFetch = window.fetch.bind(window); + let holdFirstProbe = true; + target.__probeAbortCount = 0; + target.__probeCallCount = 0; + target.__resolveFirstProbe = null; + + window.fetch = (input, init) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const candidate = new URL(url, window.location.href); + const isProbe = candidate.pathname.endsWith("/v1/agents") + && candidate.searchParams.get("limit") === "1" + && [...candidate.searchParams].length === 1; + if (isProbe) target.__probeCallCount = (target.__probeCallCount ?? 0) + 1; + if (holdFirstProbe && isProbe) { + holdFirstProbe = false; + init?.signal?.addEventListener("abort", () => { + target.__probeAbortCount = (target.__probeAbortCount ?? 0) + 1; + }, { once: true }); + return new Promise((resolve) => { + target.__resolveFirstProbe = () => resolve(new Response(JSON.stringify({ + object: "list", + data: [], + has_more: false, + first_id: null, + last_id: null, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }); + } + return originalFetch(input, init); + }; + }); + await boot(page, request); + const { dialog } = await openConnection(page); + const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); + const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); + const testConnection = dialog.getByRole("button", { name: "Test connection" }); + + await expect(local).toBeChecked(); + await expect(dialog.getByLabel("Compatible Core base URL")).toHaveCount(0); + await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__probeCallCount ?? 0 + ))).toBe(1); + await expect.poll(() => page.evaluate(() => ( + typeof (window as ProbeInstrumentationWindow).__resolveFirstProbe + ))).toBe("function"); + + await advanced.click(); + await expect(advanced).toBeChecked(); + await expect(dialog.getByRole("status")).toHaveCount(0); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__probeAbortCount ?? 0 + ))).toBe(1); + await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveFirstProbe?.()); + await expect(dialog.getByText("Core API authenticated")).toHaveCount(0); + + const directBase = `${new URL(page.url()).origin}/v1`; + const baseUrl = dialog.getByLabel("Compatible Core base URL"); + const token = dialog.getByLabel("Bearer token"); + for (const loopback of [ + "http://localhost:8091/v1", + "http://worker.localhost:8091/v1", + "http://127.0.0.42:8091/v1", + "http://[::1]:8091/v1", + ]) { + await baseUrl.fill(loopback); + await expect(testConnection).toBeEnabled(); + } + for (const unsafe of [ + "http://core.example/v1", + `${directBase}?`, + `${directBase}#`, + ]) { + await baseUrl.fill(unsafe); + await expect(dialog.getByText(/Enter an HTTPS URL, or an HTTP loopback URL/)).toBeVisible(); + await expect(testConnection).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "Apply connection" })).toBeDisabled(); + } + expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__probeCallCount)).toBe(1); + + await baseUrl.fill(directBase); + await token.fill("current-tab-token"); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await token.fill("changed-current-tab-token"); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await baseUrl.fill(`${directBase}/`); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await local.click(); + await expect(local).toBeChecked(); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); + const reopened = (await openConnection(page)).dialog; + await expect(reopened.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); + await expect(reopened.getByRole("status")).toHaveCount(0); + await expect(reopened.getByRole("alert")).toHaveCount(0); +}); + +test("announces loading, authenticated access, and each safe failure state from one GET", async ({ + page, + request, +}) => { + const replies = [ + { + status: 200, + body: { + object: "list", + data: [{ + id: "agent_empty_model", + object: "agent", + model: "", + name: null, + instructions: null, + metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + created_at: 1_700_000_000, + updated_at: 1_700_000_001, + }], + has_more: false, + first_id: "agent_empty_model", + last_id: "agent_empty_model", + }, + }, + { status: 202, body: { object: "list", data: [], has_more: false, first_id: null, last_id: null } }, + { status: 200, body: { object: "list", data: [null], has_more: false, first_id: null, last_id: null } }, + { status: 401, body: { error: { code: "invalid_api_key", message: "safe fixture failure" } } }, + { status: 401, body: { error: { code: "gateway_auth_required", message: "safe fixture failure" } } }, + { status: 400, body: { error: { code: "invalid_beta_header", message: "safe fixture failure" } } }, + { status: 503, body: { error: { code: "unavailable", message: "safe fixture failure" } } }, + { abort: true }, + ]; + const methods: string[] = []; + + await page.route(probeRequestPattern, async (route) => { + methods.push(route.request().method()); + const reply = replies.shift(); + if (!reply) return route.abort("failed"); + await new Promise((resolve) => setTimeout(resolve, 50)); + if ("abort" in reply) return route.abort("failed"); + return route.fulfill({ + status: reply.status, + contentType: "application/json", + body: JSON.stringify(reply.body), + }); + }); + + await boot(page, request); + const { dialog } = await openConnection(page); + const action = dialog.getByRole("button", { name: "Test connection" }); + const cases: Array<{ role: "status" | "alert"; text: string; absentText?: string }> = [ + { role: "status", text: "Core API authenticated" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Authentication failed" }, + { role: "alert", text: "Core returned HTTP 401", absentText: "invalid_api_key" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Core returned HTTP 503" }, + { role: "alert", text: "Core unreachable" }, + ]; + + for (const expected of cases) { + await action.click(); + const loading = dialog.getByRole("status"); + await expect(loading).toHaveAttribute("aria-live", "polite"); + await expect(loading).toContainText("Testing Core connection…"); + const terminal = dialog.getByRole(expected.role); + await expect(terminal).toContainText(expected.text); + await expect(terminal).toContainText("Execution readiness: Unknown / not verified"); + if (expected.absentText) await expect(terminal).not.toContainText(expected.absentText); + } + + expect(methods).toEqual(["GET", "GET", "GET", "GET", "GET", "GET", "GET", "GET"]); + expect(replies).toHaveLength(0); +}); + +test("turns a stalled probe into one bounded unreachable result", async ({ page, request }) => { + await page.addInitScript(() => { + const target = window as ProbeInstrumentationWindow; + const originalFetch = window.fetch.bind(window); + target.__stalledProbeCallCount = 0; + window.fetch = (input, init) => { + const value = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const candidate = new URL(value, window.location.href); + const stalledProbe = candidate.pathname.endsWith("/v1/agents") + && candidate.searchParams.get("limit") === "1"; + if (!stalledProbe) return originalFetch(input, init); + target.__stalledProbeCallCount = (target.__stalledProbeCallCount ?? 0) + 1; + return new Promise((_resolve, reject) => { + const rejectAbort = () => reject(init?.signal?.reason ?? new DOMException("Aborted", "AbortError")); + if (init?.signal?.aborted) rejectAbort(); + else init?.signal?.addEventListener("abort", rejectAbort, { once: true }); + }); + }; + }); + + await boot(page, request); + const { dialog } = await openConnection(page); + await dialog.getByRole("button", { name: "Test connection" }).click(); + await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__stalledProbeCallCount ?? 0 + ))).toBe(1); + await expect(dialog.getByRole("alert")).toContainText("Core unreachable", { timeout: 7_500 }); + await expect(dialog.getByRole("alert")).toContainText("Execution readiness: Unknown / not verified"); + expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__stalledProbeCallCount)).toBe(1); +}); + +test("supports keyboard mode selection, traps focus, and returns focus on Escape", async ({ page, request }) => { + await boot(page, request); + const { dialog, trigger } = await openConnection(page); + const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); + const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); + const apply = dialog.getByRole("button", { name: "Apply connection" }); + const close = dialog.getByRole("button", { name: "Close dialog" }); + + await expect(local).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(advanced).toBeChecked(); + await expect(advanced).toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(local).toBeChecked(); + await expect(local).toBeFocused(); + + await close.focus(); + await page.keyboard.press("Shift+Tab"); + await expect(apply).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(close).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test("keeps both connection modes operable without horizontal overflow at 390 px", async ({ page, request }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await boot(page, request); + const { dialog } = await openConnection(page); + const apply = dialog.getByRole("button", { name: "Apply connection" }); + const cancel = dialog.getByRole("button", { name: "Cancel" }); + + const assertContained = async () => { + const metrics = await page.evaluate(() => ({ + viewportWidth: innerWidth, + viewportHeight: innerHeight, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + })); + const box = await dialog.boundingBox(); + expect(box).not.toBeNull(); + expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.bodyWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(box?.x ?? -1).toBeGreaterThanOrEqual(0); + expect(box?.y ?? -1).toBeGreaterThanOrEqual(0); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(metrics.viewportWidth); + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(metrics.viewportHeight); + const widths = await dialog.evaluate((card) => { + const body = card.querySelector(".modal-body"); + return { + cardClient: card.clientWidth, + cardScroll: card.scrollWidth, + bodyClient: body?.clientWidth ?? 0, + bodyScroll: body?.scrollWidth ?? 0, + }; + }); + expect(widths.cardScroll).toBeLessThanOrEqual(widths.cardClient); + expect(widths.bodyScroll).toBeLessThanOrEqual(widths.bodyClient); + await expect(apply).toBeInViewport(); + await expect(cancel).toBeInViewport(); + }; + + await assertContained(); + const testConnection = dialog.getByRole("button", { name: "Test connection" }); + await testConnection.scrollIntoViewIfNeeded(); + await expect(testConnection).toBeInViewport(); + + await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); + await dialog.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); + await assertContained(); + await dialog.getByLabel("Compatible Core base URL").scrollIntoViewIfNeeded(); + await expect(dialog.getByLabel("Compatible Core base URL")).toBeInViewport(); +}); diff --git a/apps/web/src/components/ConnectionModal.css b/apps/web/src/components/ConnectionModal.css new file mode 100644 index 0000000..ed62025 --- /dev/null +++ b/apps/web/src/components/ConnectionModal.css @@ -0,0 +1,210 @@ +.connection-modal-modes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + padding: 0; + gap: 8px; + border: 0; +} + +.connection-modal-modes legend { + margin-bottom: 6px; + padding: 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 16px; +} + +.connection-modal-modes label { + display: flex; + min-width: 0; + padding: 9px 10px; + align-items: flex-start; + gap: 8px; + cursor: pointer; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-modes label.selected { + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); +} + +.connection-modal-modes input { + flex: 0 0 auto; + margin: 2px 0 0; + accent-color: var(--accent); +} + +.connection-modal-modes label > span { + display: flex; + min-width: 0; + flex-direction: column; +} + +.connection-modal-modes strong, +.connection-modal-modes small { + overflow-wrap: anywhere; +} + +.connection-modal-modes strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +.connection-modal-modes small { + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-modal-local { + margin-top: 12px; + padding: 12px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-local .connection-guide-header > svg { + color: var(--success); +} + +.connection-modal-local dl { + margin: 10px 0 0; +} + +.connection-modal-local dl > div { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + padding: 5px 0; + gap: 8px; + border-top: 1px solid var(--line); +} + +.connection-modal-local dt { + color: var(--fg-muted); +} + +.connection-modal-local dd { + min-width: 0; + margin: 0; + color: var(--fg); + overflow-wrap: anywhere; +} + +.connection-modal-local code { + padding: 1px 4px; + color: var(--fg); + font-family: var(--font-mono); + font-size: 11px; + background: var(--surface-muted); + border-radius: 4px; +} + +.connection-modal-local-note { + margin: 8px 0 0; +} + +.connection-modal-advanced { + margin-top: 12px; +} + +.connection-modal-test { + display: flex; + margin-top: 12px; + padding: 10px 0; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.connection-modal-test > div { + min-width: 0; +} + +.connection-modal-test strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; +} + +.connection-modal-test p { + margin: 2px 0 0; + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-modal-test .button { + flex: 0 0 auto; +} + +.connection-modal-probe-result { + margin-top: 10px; + padding: 9px 10px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-probe-result.succeeded { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); +} + +.connection-modal-probe-result.failed { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); +} + +.connection-modal-probe-result strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; +} + +.connection-modal-probe-result.failed strong { + color: var(--danger); +} + +.connection-modal-probe-result p { + margin: 2px 0 0; +} + +.connection-modal-probe-result small { + display: block; + margin-top: 4px; + color: var(--fg-muted); + font-size: 11px; +} + +@media (max-width: 520px) { + .connection-modal-modes { + grid-template-columns: 1fr; + } + + .connection-modal-test { + align-items: stretch; + flex-direction: column; + } + + .connection-modal-test .button { + width: 100%; + } + + .connection-modal-local dl > div { + grid-template-columns: 1fr; + gap: 2px; + } +} diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx index c103c8e..b4536fd 100644 --- a/apps/web/src/components/ConnectionModal.test.tsx +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -1,85 +1,200 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { ConnectionModal } from "./ConnectionModal"; +import { + ConnectionModal, + ConnectionProbeStatus, + type ConnectionProbeState, +} from "./ConnectionModal"; const callbacks = { onClose: () => undefined, onSave: () => undefined, }; -describe("Agent Core connection guidance", () => { - it("links setup guidance to the immutable Parsar compatibility baseline", () => { - const markup = renderToStaticMarkup( - , - ); - const baseline = "0438880ab21aa16d05cb91a4c7f91cc0abc12358"; +function renderModal(baseUrl: string, proxyAuthEnabled: boolean, token = "") { + return renderToStaticMarkup( + , + ); +} - expect(markup).toContain( - `href="https://github.com/MiniMax-AI-Dev/parsar/blob/${baseline}/services/agents-api/README.md#standalone-http-service"`, - ); - expect(markup).toContain( - `href="https://github.com/MiniMax-AI-Dev/parsar/blob/${baseline}/services/agents-api/README.md#internal-execution-device-connection"`, - ); +describe("Agent Core connection modes", () => { + it("makes the fixed local Parsar path a zero-input default", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Local Parsar Core"); + expect(markup).toContain("Default · zero input"); + expect(markup).toMatch(/type="radio"[^>]*checked[^>]*value="local"/); + expect(markup).toContain("Local `/v1` proxy"); + expect(markup).toContain("No URL is required"); + expect(markup).not.toContain("Compatible Core base URL"); + expect(markup).not.toContain('type="password"'); + }); + + it("does not request or expose a browser bearer when proxy auth is active", () => { + const markup = renderModal("/v1", true, "stale-browser-token"); + + expect(markup).toContain("Server-managed key detected"); + expect(markup).toContain("No bearer credential is exposed to browser JavaScript"); + expect(markup).not.toContain("stale-browser-token"); + expect(markup).not.toContain("Bearer token"); + expect(markup).not.toContain("sessionStorage"); + }); + + it("keeps local mode token-free when server-managed auth is not detected", () => { + const markup = renderModal("/v1", false, "stale-browser-token"); + + expect(markup).toContain("Server-managed key not detected"); + expect(markup).toContain("Configure the local proxy token file and restart Web"); + expect(markup).toContain("Local mode will not request a browser token"); + expect(markup).not.toContain("stale-browser-token"); + expect(markup).not.toContain('type="password"'); + }); + + it("puts direct URLs, CORS guidance, and current-tab tokens behind Advanced", () => { + const markup = renderModal("http://127.0.0.1:8091/v1", true); + + expect(markup).toMatch(/type="radio"[^>]*checked[^>]*value="advanced"/); + expect(markup).toContain("Compatible Core base URL"); + expect(markup).toContain("through CORS"); + expect(markup).toContain('type="password"'); + expect(markup).toContain("Current tab only"); + expect(markup).toContain("sessionStorage, never localStorage"); + expect(markup).not.toContain("Server-managed key detected"); + }); + + it("rejects credentials and query data embedded in an Advanced URL", () => { + const markup = renderModal("https://core.example/v1?token=unsafe", true); + + expect(markup).toContain("an HTTP loopback URL, without credentials, query parameters, or fragments"); + expect(markup).toContain('aria-invalid="true"'); + expect(markup.match(/disabled=""/g)).toHaveLength(2); + }); + + it("rejects remote HTTP while explaining the HTTPS and loopback boundary", () => { + const markup = renderModal("http://core.example/v1", true, "must-not-leave-browser"); + + expect(markup).toContain("Remote Core access requires HTTPS"); + expect(markup).toContain("Plain HTTP is allowed only for an explicit loopback host"); + expect(markup).toContain('aria-invalid="true"'); + expect(markup.match(/disabled=""/g)).toHaveLength(2); + }); + + it("replaces inline credential tutorials with concise operator links", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Operator-owned setup"); + expect(markup).toContain("Connection guide"); + expect(markup).toContain("Troubleshooting"); + expect(markup).toContain("Parsar Core setup"); + expect(markup).toContain("98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"); + expect(markup).toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); + expect(markup).not.toContain("f7cdf591396529880d80f8211fc7a0f4768fdf46"); expect(markup).not.toContain("8cc2898ca42b272cb3771234ee6a0ad0d2e932ba"); + expect(markup).not.toContain("7409e00ca25311805a9f8f0d03614f820e407642"); + expect(markup).not.toContain("6345391"); + expect(markup).not.toContain("bac551c"); + expect(markup).not.toContain("openssl rand"); + expect(markup).not.toContain("AGENTS_API_KEYS_FILE"); + expect(markup).not.toContain("AGENTS_API_DAEMON_WS_URL"); }); - it("explains key generation and the server-side file path contract", () => { - const markup = renderToStaticMarkup( - , - ); + it("states the GET-only probe boundary and unknown execution readiness", () => { + const markup = renderModal("/v1", true); - expect(markup).toContain("Connect the local proxy key"); - expect(markup).toContain("openssl rand -hex 32"); - expect(markup).toContain("Core does not issue it automatically"); - expect(markup).toContain("tenant, organization, project, and subject"); - expect(markup).toContain("AGENTS_API_KEYS_FILE"); - expect(markup).toContain("AGENTS_API_PROXY_TOKEN_FILE"); - expect(markup).toContain("Agent Core setup"); - expect(markup).toContain("AGENTS_API_DAEMON_WS_URL"); - expect(markup).toContain("same-tenant"); - expect(markup).toContain("Executor setup"); + expect(markup).toContain("Test connection"); + expect(markup).toContain("with one GET"); + expect(markup).toContain("never creates an Agent, Session, Turn, or Item"); + expect(markup).toContain("Execution readiness remains Unknown / not verified"); + expect(markup).toContain("does not prove a daemon, model, or provider is ready"); }); +}); - it("shows when the local proxy already owns the bearer credential", () => { - const markup = renderToStaticMarkup( - , - ); +describe("Connection probe status", () => { + it("announces the loading state", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="status"'); + expect(markup).toContain('aria-live="polite"'); + expect(markup).toContain("Testing Core connection…"); + expect(markup).toContain("one read-only Agents API GET request"); + }); - expect(markup).toContain("Server-managed Core key active"); - expect(markup).toContain("Server key active"); - expect(markup).toContain("Using the server-managed Core key"); + it.each<[ConnectionProbeState, string]>([ + [ + { + status: "complete", + result: { kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }, + }, + "Core API authenticated", + ], + [ + { + status: "complete", + result: { kind: "invalid_configuration", executionReadiness: "unknown" }, + }, + "Core URL blocked", + ], + [ + { + status: "complete", + result: { kind: "unauthorized", executionReadiness: "unknown", httpStatus: 401 }, + }, + "401 invalid_api_key", + ], + [ + { + status: "complete", + result: { kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: 400 }, + }, + "Agents API protocol mismatch", + ], + [ + { + status: "complete", + result: { kind: "unreachable", executionReadiness: "unknown" }, + }, + "Core unreachable", + ], + [ + { + status: "complete", + result: { kind: "http_error", executionReadiness: "unknown", httpStatus: 503 }, + }, + "Core returned HTTP 503", + ], + ])("renders a safe, distinct terminal result", (state, expected) => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain(expected); + expect(markup).toContain("Execution readiness: Unknown / not verified"); + expect(markup).not.toContain("Execution readiness: Ready"); }); - it("keeps direct connections separate from the local proxy key file", () => { - const markup = renderToStaticMarkup( - { + const failed = renderToStaticMarkup( + , + ); + const succeeded = renderToStaticMarkup( + , ); - expect(markup).toContain("Use a current-tab token"); - expect(markup).toContain("CORS-enabled compatible Core"); - expect(markup).toContain("sessionStorage, never localStorage"); - expect(markup).toContain("The proxy token file does not apply to direct URLs"); - expect(markup).not.toContain("AGENTS_API_PROXY_TOKEN_FILE"); + expect(failed).toContain('role="alert"'); + expect(succeeded).toContain('role="status"'); }); }); diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx index 01ac1dc..63ccc76 100644 --- a/apps/web/src/components/ConnectionModal.tsx +++ b/apps/web/src/components/ConnectionModal.tsx @@ -1,8 +1,17 @@ import { ExternalLink, Info, KeyRound } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; -import { isLocalProxyBaseUrl, type CoreConnection } from "../lib/connection"; +import { + isLocalProxyBaseUrl, + isValidDirectCoreBaseUrl, + type CoreConnection, +} from "../lib/connection"; +import { + probeCore, + type CoreProbeResult, +} from "../lib/core-probe"; import { Modal } from "./Modal"; +import "./ConnectionModal.css"; interface ConnectionModalProps { connection: CoreConnection; @@ -12,9 +21,91 @@ interface ConnectionModalProps { onSave: (connection: CoreConnection) => void; } +type ConnectionMode = "local" | "advanced"; + +export type ConnectionProbeState = + | { status: "idle" } + | { status: "loading" } + | { status: "complete"; result: CoreProbeResult }; + +const webBaseline = "98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"; const parsarBaseline = "0438880ab21aa16d05cb91a4c7f91cc0abc12358"; -const coreSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#standalone-http-service`; -const executorSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#internal-execution-device-connection`; +const operatorGuideUrl = `https://github.com/MiniMax-AI-Dev/agents-core-web/blob/${webBaseline}/docs/core-connection.md`; +const troubleshootingUrl = `${operatorGuideUrl}#troubleshooting`; +const parsarCoreSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#standalone-http-service`; + +function initialMode(connection: CoreConnection): ConnectionMode { + return isLocalProxyBaseUrl(connection.baseUrl) ? "local" : "advanced"; +} + +function initialAdvancedDraft(connection: CoreConnection): CoreConnection { + const direct = !isLocalProxyBaseUrl(connection.baseUrl); + return { + baseUrl: direct ? connection.baseUrl : "", + token: direct ? connection.token : "", + }; +} + +function resultCopy(result: CoreProbeResult): { title: string; detail: string } { + switch (result.kind) { + case "authenticated": + return { + title: "Core API authenticated", + detail: "The read-only Agents API request returned a valid collection.", + }; + case "invalid_configuration": + return { + title: "Core URL blocked", + detail: "Remote Core URLs must use HTTPS. Plain HTTP is allowed only for an explicit loopback host.", + }; + case "unauthorized": + return { + title: "Authentication failed", + detail: "Core returned 401 invalid_api_key. Check the server-managed caller key or current-tab token.", + }; + case "protocol_mismatch": + return { + title: "Agents API protocol mismatch", + detail: "The endpoint did not accept the tested /v1/agents GET contract and OpenAI-Beta: agents=v1 header.", + }; + case "http_error": + return { + title: `Core returned HTTP ${result.httpStatus ?? "error"}`, + detail: "The basic Agents API read did not succeed. No Core resource was changed.", + }; + case "unreachable": + return { + title: "Core unreachable", + detail: "The browser could not reach Core. Check the local proxy target, network, or direct-mode CORS policy.", + }; + } +} + +export function ConnectionProbeStatus({ state }: { state: ConnectionProbeState }) { + if (state.status === "idle") return null; + if (state.status === "loading") { + return ( +
+ Testing Core connection… +

Sending one read-only Agents API GET request.

+
+ ); + } + + const copy = resultCopy(state.result); + const failed = state.result.kind !== "authenticated"; + return ( +
+ {copy.title} +

{copy.detail}

+ Execution readiness: Unknown / not verified. +
+ ); +} export function ConnectionModal({ connection, @@ -23,120 +114,224 @@ export function ConnectionModal({ onClose, onSave, }: ConnectionModalProps) { - const [draft, setDraft] = useState(connection); - const localProxyDraft = isLocalProxyBaseUrl(draft.baseUrl); - const proxyOwnsDraft = proxyAuthEnabled && localProxyDraft; + const modeName = useId(); + const [mode, setMode] = useState(() => initialMode(connection)); + const [advancedDraft, setAdvancedDraft] = useState(() => initialAdvancedDraft(connection)); + const [probeState, setProbeState] = useState({ status: "idle" }); + const probeControllerRef = useRef(undefined); + const probeRevisionRef = useRef(0); + const advancedUrlValid = isValidDirectCoreBaseUrl(advancedDraft.baseUrl); + const draft = mode === "local" + ? { baseUrl: "/v1", token: "" } + : { baseUrl: advancedDraft.baseUrl.trim(), token: advancedDraft.token.trim() }; + + useEffect(() => { + setMode(initialMode(connection)); + setAdvancedDraft(initialAdvancedDraft(connection)); + }, [connection, open]); + + useEffect(() => { + probeRevisionRef.current += 1; + probeControllerRef.current?.abort(); + probeControllerRef.current = undefined; + setProbeState({ status: "idle" }); + }, [advancedDraft.baseUrl, advancedDraft.token, mode, open, proxyAuthEnabled]); - useEffect(() => setDraft(connection), [connection, open]); + useEffect(() => () => probeControllerRef.current?.abort(), []); + + const testConnection = async () => { + const controller = new AbortController(); + probeControllerRef.current?.abort(); + probeControllerRef.current = controller; + const revision = ++probeRevisionRef.current; + setProbeState({ status: "loading" }); + + try { + const result = await probeCore({ + baseUrl: draft.baseUrl, + token: mode === "advanced" ? draft.token : undefined, + signal: controller.signal, + }); + if (probeRevisionRef.current !== revision || controller.signal.aborted) return; + probeControllerRef.current = undefined; + setProbeState({ status: "complete", result }); + } catch { + if (probeRevisionRef.current !== revision || controller.signal.aborted) return; + probeControllerRef.current = undefined; + setProbeState({ + status: "complete", + result: { kind: "unreachable", executionReadiness: "unknown" }, + }); + } + }; + + const testDisabled = probeState.status === "loading" || (mode === "advanced" && !advancedUrlValid); + const applyDisabled = mode === "advanced" && !advancedUrlValid; return ( - } > -
-
-
+ + + {mode === "local" ? ( +
+
+
+
+
+
API base
+
/v1
+
+
+
Authentication
+
{proxyAuthEnabled ? "Server-managed key detected" : "Server-managed key not detected"}
+
+
+

+ {proxyAuthEnabled + ? "The Vite proxy supplies its key server-side. No bearer credential is exposed to browser JavaScript." + : "Configure the local proxy token file and restart Web. Local mode will not request a browser token."} +

+
+ ) : ( +
+ + +
+ )} + +
+
+ Connection test +

Checks authenticated Agents API access with one GET. It never creates an Agent, Session, Turn, or Item.

+
+ +
+ + +
-
-
    -
  1. - Generate the plaintext caller key with openssl rand -hex 32; Core does not issue it - automatically. -
  2. -
  3. - Put its SHA-256 digest plus the tenant, organization, project, and subject binding in Core{" "} - keys.json. -
  4. -
  5. - Start Core with AGENTS_API_KEYS_FILE; {localProxyDraft ? ( - <> - point Web at the plaintext file with AGENTS_API_PROXY_TOKEN_FILE. - - ) : ( - <>paste the plaintext caller bearer above. The proxy token file does not apply to direct URLs. - )} -
  6. -
  7. - To chat, enable AGENTS_API_DAEMON_WS_URL and connect a same-tenant{" "} - parsar-daemon executor. -
  8. -
); diff --git a/apps/web/src/lib/connection.test.ts b/apps/web/src/lib/connection.test.ts index 661461a..0914e52 100644 --- a/apps/web/src/lib/connection.test.ts +++ b/apps/web/src/lib/connection.test.ts @@ -1,6 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { isLocalProxyBaseUrl, loadConnection, saveConnection } from "./connection"; +import { + createCore, + InvalidCoreConnectionError, + isLocalProxyBaseUrl, + isValidDirectCoreBaseUrl, + loadConnection, + saveConnection, +} from "./connection"; function memoryStorage(): Storage { const values = new Map(); @@ -21,6 +28,8 @@ beforeEach(() => { vi.stubGlobal("sessionStorage", memoryStorage()); }); +afterEach(() => vi.unstubAllGlobals()); + describe("Agent Core connection authentication", () => { it("recognizes only the same-origin /v1 proxy boundary", () => { expect(isLocalProxyBaseUrl("/v1")).toBe(true); @@ -28,18 +37,139 @@ describe("Agent Core connection authentication", () => { expect(isLocalProxyBaseUrl("http://127.0.0.1:8091/v1")).toBe(false); }); - it("removes a stale browser token when the local proxy owns authentication", () => { + it.each([ + "https://core.example/v1", + "http://localhost:8091/v1", + "http://worker.localhost:8091/v1", + "http://127.0.0.42:8091/v1", + "http://2130706433:8091/v1", + "http://[::1]:8091/v1", + "http://[0:0:0:0:0:0:0:1]:8091/v1", + "https://core.example/v1%3Ftenant%3Dsafe", + "https://core.example/v1%23section", + "https://core.example/v1/@scope", + ])("allows HTTPS or an explicit HTTP loopback direct Core: %s", (baseUrl) => { + expect(isValidDirectCoreBaseUrl(baseUrl)).toBe(true); + }); + + it.each([ + "http://core.example/v1", + "http://192.168.1.20:8091/v1", + "http://localhost.example/v1", + "http://127.0.0.1.example/v1", + "http://127.0.0.1%2eexample/v1", + "http://[::2]:8091/v1", + "ftp://core.example/v1", + "https://user:secret@core.example/v1", + "https://@core.example/v1", + "https://:@core.example/v1", + "https:@core.example/v1", + "https://core.example/v1?", + "https://core.example/v1#", + "https://core.example/v1?#", + "https://core.example/v1?token=secret", + "https://core.example/v1#secret", + ])("rejects an unsafe direct Core URL: %s", (baseUrl) => { + expect(isValidDirectCoreBaseUrl(baseUrl)).toBe(false); + }); + + it.each([true, false])("removes a stale local token with proxy auth enabled=%s", (proxyAuthEnabled) => { sessionStorage.setItem("agents-core-web.core-token", "stale-browser-token"); - expect(loadConnection(true)).toEqual({ baseUrl: "/v1", token: "" }); + expect(loadConnection(proxyAuthEnabled)).toEqual({ baseUrl: "/v1", token: "" }); + expect(sessionStorage.getItem("agents-core-web.core-token")).toBeNull(); + }); + + it("does not save a browser token for the local proxy when proxy auth is disabled", () => { + sessionStorage.setItem("agents-core-web.core-token", "historical-browser-token"); + + saveConnection({ baseUrl: "/v1", token: "new-browser-token" }, false); + + expect(localStorage.getItem("agents-core-web.core-base-url")).toBe("/v1"); + expect(sessionStorage.getItem("agents-core-web.core-token")).toBeNull(); + }); + + it("scrubs a legacy remote HTTP URL and current-tab token", () => { + localStorage.setItem("agents-core-web.core-base-url", "http://core.example/v1"); + sessionStorage.setItem("agents-core-web.core-token", "legacy-remote-token"); + + expect(loadConnection(false)).toEqual({ baseUrl: "/v1", token: "" }); + expect(localStorage.getItem("agents-core-web.core-base-url")).toBe("/v1"); + expect(sessionStorage.getItem("agents-core-web.core-token")).toBeNull(); + }); + + it("scrubs an unsafe direct connection instead of persisting its URL or token", () => { + const unsafe = { baseUrl: "http://core.example/v1", token: "must-not-leave-browser" }; + + saveConnection(unsafe, false); + + expect(localStorage.getItem("agents-core-web.core-base-url")).toBe("/v1"); + expect(sessionStorage.getItem("agents-core-web.core-token")).toBeNull(); + }); + + it.each([ + "http://core.example/v1", + "https://core.example/v1?", + "https://core.example/v1#", + ])("fails closed before creating a client for an unsafe runtime target: %s", (baseUrl) => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + vi.stubGlobal("fetch", (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(JSON.stringify({ data: [], has_more: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch); + const token = "must-not-leave-browser"; + let error: unknown; + + try { + createCore({ baseUrl, token }, false); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(InvalidCoreConnectionError); + expect(error).toMatchObject({ code: "invalid_core_base_url" }); + expect(String(error)).not.toContain(baseUrl); + expect(String(error)).not.toContain(token); + expect(calls).toHaveLength(0); + }); + + it("creates a token-free local client and clears historical storage when proxy auth is disabled", async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + vi.stubGlobal("fetch", (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(JSON.stringify({ data: [], has_more: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch); + sessionStorage.setItem("agents-core-web.core-token", "historical-browser-token"); + + const core = createCore({ baseUrl: "/v1", token: "connection-browser-token" }, false); + await core.listAgents(); + expect(sessionStorage.getItem("agents-core-web.core-token")).toBeNull(); + expect(String(calls[0]?.input)).toBe("/v1/agents"); + expect(new Headers(calls[0]?.init?.headers).has("Authorization")).toBe(false); }); - it("keeps a manual token for a direct Core URL", () => { + it("keeps a manual token for a direct Core URL", async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + vi.stubGlobal("fetch", (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(JSON.stringify({ data: [], has_more: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch); const connection = { baseUrl: "http://127.0.0.1:8091/v1", token: "manual-token" }; - saveConnection(connection, true); + saveConnection(connection, false); - expect(loadConnection(true)).toEqual(connection); + expect(loadConnection(false)).toEqual(connection); expect(sessionStorage.getItem("agents-core-web.core-token")).toBe("manual-token"); + await createCore(connection, false).listAgents(); + expect(new Headers(calls[0]?.init?.headers).get("Authorization")).toBe("Bearer manual-token"); }); }); diff --git a/apps/web/src/lib/connection.ts b/apps/web/src/lib/connection.ts index 9bbe7f9..6bddc50 100644 --- a/apps/web/src/lib/connection.ts +++ b/apps/web/src/lib/connection.ts @@ -7,6 +7,15 @@ export interface CoreConnection { export type CoreConnectionState = "connecting" | "ready" | "failed"; +export class InvalidCoreConnectionError extends Error { + readonly code = "invalid_core_base_url"; + + constructor() { + super("The Agent Core base URL is invalid or unsafe."); + this.name = "InvalidCoreConnectionError"; + } +} + const BASE_URL_KEY = "agents-core-web.core-base-url"; const TOKEN_KEY = "agents-core-web.core-token"; const devProxyAuthEnabled = import.meta.env.DEV && __AGENTS_CORE_WEB_DEV_PROXY_AUTH__; @@ -15,32 +24,78 @@ export function isLocalProxyBaseUrl(baseUrl: string): boolean { return (baseUrl.trim() || "/v1").replace(/\/+$/, "") === "/v1"; } -function usesServerManagedAuth(baseUrl: string, proxyAuthEnabled: boolean): boolean { - return proxyAuthEnabled && isLocalProxyBaseUrl(baseUrl); +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized === "[::1]" || + /^127(?:\.\d{1,3}){3}$/.test(normalized) + ); +} + +function hasExplicitUserInfo(candidate: string): boolean { + const schemeEnd = candidate.indexOf(":"); + if (schemeEnd < 0) return false; + const remainder = candidate.slice(schemeEnd + 1).replace(/^[\\/]+/, ""); + const authorityEnd = remainder.search(/[\\/?#]/); + const authority = authorityEnd < 0 ? remainder : remainder.slice(0, authorityEnd); + return authority.includes("@"); +} + +export function isValidDirectCoreBaseUrl(value: string): boolean { + try { + const candidate = value.trim(); + if (candidate.includes("?") || candidate.includes("#") || hasExplicitUserInfo(candidate)) return false; + const url = new URL(candidate); + const secureTransport = url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHostname(url.hostname)); + return secureTransport && !url.username && !url.password && !url.search && !url.hash; + } catch { + return false; + } } -export function loadConnection(proxyAuthEnabled = devProxyAuthEnabled): CoreConnection { - const baseUrl = localStorage.getItem(BASE_URL_KEY) ?? "/v1"; - const serverManaged = usesServerManagedAuth(baseUrl, proxyAuthEnabled); - if (serverManaged) sessionStorage.removeItem(TOKEN_KEY); +function usesCurrentTabToken(baseUrl: string): boolean { + return !isLocalProxyBaseUrl(baseUrl) && isValidDirectCoreBaseUrl(baseUrl); +} + +function safeStoredBaseUrl(baseUrl: string): string { + const candidate = baseUrl.trim() || "/v1"; + return isLocalProxyBaseUrl(candidate) || isValidDirectCoreBaseUrl(candidate) ? candidate : "/v1"; +} + +function runtimeBaseUrl(baseUrl: string): string { + const candidate = baseUrl.trim() || "/v1"; + if (isLocalProxyBaseUrl(candidate) || isValidDirectCoreBaseUrl(candidate)) return candidate; + throw new InvalidCoreConnectionError(); +} + +export function loadConnection(_proxyAuthEnabled = devProxyAuthEnabled): CoreConnection { + const storedBaseUrl = localStorage.getItem(BASE_URL_KEY) ?? "/v1"; + const baseUrl = safeStoredBaseUrl(storedBaseUrl); + if (baseUrl !== storedBaseUrl) localStorage.setItem(BASE_URL_KEY, baseUrl); + const currentTabToken = usesCurrentTabToken(baseUrl); + if (!currentTabToken) sessionStorage.removeItem(TOKEN_KEY); return { baseUrl, - token: serverManaged ? "" : sessionStorage.getItem(TOKEN_KEY) ?? "", + token: currentTabToken ? sessionStorage.getItem(TOKEN_KEY) ?? "" : "", }; } -export function saveConnection(connection: CoreConnection, proxyAuthEnabled = devProxyAuthEnabled): void { - localStorage.setItem(BASE_URL_KEY, connection.baseUrl || "/v1"); - if (connection.token && !usesServerManagedAuth(connection.baseUrl, proxyAuthEnabled)) { +export function saveConnection(connection: CoreConnection, _proxyAuthEnabled = devProxyAuthEnabled): void { + const baseUrl = safeStoredBaseUrl(connection.baseUrl); + localStorage.setItem(BASE_URL_KEY, baseUrl); + if (connection.token && usesCurrentTabToken(baseUrl)) { sessionStorage.setItem(TOKEN_KEY, connection.token); } else sessionStorage.removeItem(TOKEN_KEY); } -export function createCore(connection: CoreConnection, proxyAuthEnabled = devProxyAuthEnabled): AgentCore { +export function createCore(connection: CoreConnection, _proxyAuthEnabled = devProxyAuthEnabled): AgentCore { + const baseUrl = runtimeBaseUrl(connection.baseUrl); + const currentTabToken = usesCurrentTabToken(baseUrl); + if (!currentTabToken) sessionStorage.removeItem(TOKEN_KEY); return new OpenAIAgentsClient({ - baseUrl: connection.baseUrl || "/v1", - token: usesServerManagedAuth(connection.baseUrl, proxyAuthEnabled) - ? undefined - : () => sessionStorage.getItem(TOKEN_KEY) ?? connection.token, + baseUrl, + token: currentTabToken ? () => sessionStorage.getItem(TOKEN_KEY) ?? connection.token : undefined, }); } diff --git a/apps/web/src/lib/core-probe.test.ts b/apps/web/src/lib/core-probe.test.ts new file mode 100644 index 0000000..1e9c593 --- /dev/null +++ b/apps/web/src/lib/core-probe.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vitest"; + +import { isValidDirectCoreBaseUrl } from "./connection"; +import { coreProbeUrl, probeCore } from "./core-probe"; + +interface FetchCall { + input: RequestInfo | URL; + init?: RequestInit; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function recordingFetch(response: Response, calls: FetchCall[]): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }); + return response; + }) as typeof fetch; +} + +function stalledBodyResponse(status: number, signal: AbortSignal): Response { + const body = new ReadableStream({ + start(controller) { + const failOnAbort = () => controller.error(signal.reason ?? new DOMException("Aborted", "AbortError")); + if (signal.aborted) failOnAbort(); + else signal.addEventListener("abort", failOnAbort, { once: true }); + }, + }); + return new Response(body, { status, headers: { "Content-Type": "application/json" } }); +} + +const canonicalAgent = { + id: "agent_probe", + object: "agent", + model: "fixture/model", + name: "Probe Agent", + instructions: "Read-only probe fixture", + metadata: { fixture: "safe" }, + multi_agent: { enabled: false, max_concurrent_subagents: null } as { + enabled: boolean; + max_concurrent_subagents: number | null; + }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [] as unknown[], + created_at: 1_700_000_000, + updated_at: 1_700_000_001, +}; + +function canonicalPage(data: Array = []) { + return { + object: "list", + data, + has_more: false, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }; +} + +describe("Core connection probe", () => { + it("uses one read-only Agents API request with the required beta header", async () => { + const calls: FetchCall[] = []; + + const result = await probeCore({ + baseUrl: "/v1/", + fetch: recordingFetch(jsonResponse(canonicalPage()), calls), + }); + + expect(result).toEqual({ kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }); + expect(calls).toHaveLength(1); + expect(String(calls[0]?.input)).toBe("/v1/agents?limit=1"); + expect(calls[0]?.init?.method).toBe("GET"); + expect(calls[0]?.init?.body).toBeUndefined(); + expect(calls[0]?.init?.cache).toBe("no-store"); + const headers = new Headers(calls[0]?.init?.headers); + expect(headers.get("Accept")).toBe("application/json"); + expect(headers.get("OpenAI-Beta")).toBe("agents=v1"); + expect(headers.has("Authorization")).toBe(false); + }); + + it("uses a direct-mode bearer only in the authorization header", async () => { + const calls: FetchCall[] = []; + const token = "current-tab-secret"; + + const result = await probeCore({ + baseUrl: "https://core.example/v1/", + token, + fetch: recordingFetch(jsonResponse(canonicalPage()), calls), + }); + + expect(result.kind).toBe("authenticated"); + expect(String(calls[0]?.input)).toBe("https://core.example/v1/agents?limit=1"); + expect(new Headers(calls[0]?.init?.headers).get("Authorization")).toBe(`Bearer ${token}`); + expect(JSON.stringify(result)).not.toContain(token); + }); + + it("ignores a historical browser bearer on the local proxy path", async () => { + const calls: FetchCall[] = []; + + const result = await probeCore({ + baseUrl: "/v1", + token: "stale-browser-secret", + fetch: recordingFetch(jsonResponse(canonicalPage()), calls), + }); + + expect(result.kind).toBe("authenticated"); + expect(new Headers(calls[0]?.init?.headers).has("Authorization")).toBe(false); + }); + + it("classifies 401 without reflecting an upstream secret-bearing message", async () => { + const token = "do-not-reflect"; + const result = await probeCore({ + baseUrl: "https://core.example/v1", + token, + fetch: recordingFetch( + jsonResponse({ error: { code: "invalid_api_key", message: `bad ${token}` } }, 401), + [], + ), + }); + + expect(result).toEqual({ kind: "unauthorized", executionReadiness: "unknown", httpStatus: 401 }); + expect(JSON.stringify(result)).not.toContain(token); + }); + + it.each([ + jsonResponse({ error: { code: "gateway_auth_required" } }, 401), + new Response("proxy login required", { status: 401 }), + ])("does not claim invalid_api_key for a non-canonical 401", async (response) => { + const result = await probeCore({ baseUrl: "/v1", fetch: recordingFetch(response, []) }); + + expect(result).toEqual({ kind: "http_error", executionReadiness: "unknown", httpStatus: 401 }); + }); + + it("accepts a canonical non-empty Agents page", async () => { + const result = await probeCore({ + baseUrl: "/v1", + fetch: recordingFetch(jsonResponse(canonicalPage([canonicalAgent])), []), + }); + + expect(result).toEqual({ kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }); + }); + + it("accepts a canonical SavedAgent whose required model field is an empty string", async () => { + const result = await probeCore({ + baseUrl: "/v1", + fetch: recordingFetch(jsonResponse(canonicalPage([{ ...canonicalAgent, model: "" }])), []), + }); + + expect(result).toEqual({ kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }); + }); + + it("accepts MCP and unknown non-empty tool types for a basic-read probe", async () => { + const tools = [ + { type: "mcp", server_label: "fixture-server" }, + { type: "future_tool_type", future_field: true }, + ]; + const result = await probeCore({ + baseUrl: "/v1", + fetch: recordingFetch(jsonResponse(canonicalPage([{ ...canonicalAgent, tools }])), []), + }); + + expect(result).toEqual({ kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }); + }); + + it.each([ + null, + {}, + { type: "" }, + ])("rejects a malformed SavedAgent tool descriptor: %j", async (tool) => { + const result = await probeCore({ + baseUrl: "/v1", + fetch: recordingFetch(jsonResponse(canonicalPage([{ ...canonicalAgent, tools: [tool] }])), []), + }); + + expect(result).toEqual({ kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: 200 }); + }); + + it.each([ + { enabled: true, max_concurrent_subagents: 1 }, + { enabled: true, max_concurrent_subagents: 4_294_967_295 }, + { enabled: false, max_concurrent_subagents: null }, + ])("accepts a canonical multi_agent relationship: %j", async (multiAgent) => { + const result = await probeCore({ + baseUrl: "/v1", + fetch: recordingFetch(jsonResponse(canonicalPage([{ ...canonicalAgent, multi_agent: multiAgent }])), []), + }); + + expect(result).toEqual({ kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }); + }); + + it.each([ + { enabled: true, max_concurrent_subagents: null }, + { enabled: false, max_concurrent_subagents: 4 }, + { enabled: true, max_concurrent_subagents: 0 }, + { enabled: true, max_concurrent_subagents: 4_294_967_296 }, + ])("rejects an inconsistent or out-of-range multi_agent relationship: %j", async (multiAgent) => { + const result = await probeCore({ + baseUrl: "/v1", + fetch: recordingFetch(jsonResponse(canonicalPage([{ ...canonicalAgent, multi_agent: multiAgent }])), []), + }); + + expect(result).toEqual({ kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: 200 }); + }); + + it.each([ + [jsonResponse({ error: { code: "invalid_beta_header" } }, 400), 400], + [jsonResponse({ error: { code: "not_found" } }, 404), 404], + [jsonResponse({ error: { code: "method_not_allowed" } }, 405), 405], + [jsonResponse(canonicalPage(), 201), 201], + [jsonResponse(canonicalPage(), 202), 202], + [jsonResponse({ ok: true }), 200], + [new Response("not json", { status: 200 }), 200], + ])("classifies beta, route, and response-shape mismatches", async (response, status) => { + const result = await probeCore({ baseUrl: "/v1", fetch: recordingFetch(response, []) }); + + expect(result).toEqual({ kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: status }); + }); + + it.each([ + { ...canonicalPage(), object: "collection" }, + { ...canonicalPage(), first_id: "wrong" }, + { ...canonicalPage(), last_id: 42 }, + { ...canonicalPage(), has_more: true }, + { ...canonicalPage(), data: [null], first_id: null, last_id: null }, + { ...canonicalPage([canonicalAgent]), data: [{ ...canonicalAgent, model: undefined }] }, + { ...canonicalPage([canonicalAgent]), data: [{ ...canonicalAgent, updated_at: undefined }] }, + ])("rejects a malformed Agents list envelope or item", async (payload) => { + const result = await probeCore({ baseUrl: "/v1", fetch: recordingFetch(jsonResponse(payload), []) }); + + expect(result).toEqual({ kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: 200 }); + }); + + it("separates other HTTP failures from authentication and protocol failures", async () => { + const result = await probeCore({ + baseUrl: "/v1", + fetch: recordingFetch(jsonResponse({ error: { code: "unavailable" } }, 503), []), + }); + + expect(result).toEqual({ kind: "http_error", executionReadiness: "unknown", httpStatus: 503 }); + }); + + it("classifies a network or CORS rejection without retrying", async () => { + let calls = 0; + const result = await probeCore({ + baseUrl: "https://core.example/v1", + fetch: (async () => { + calls += 1; + throw new TypeError("Failed to fetch a private URL"); + }) as typeof fetch, + }); + + expect(result).toEqual({ kind: "unreachable", executionReadiness: "unknown" }); + expect(calls).toBe(1); + }); + + it.each([ + "http://core.example/v1", + "https://core.example/v1?", + "https://core.example/v1#", + "https://@core.example/v1", + ])("blocks an unsafe direct Core before sending a bearer or request: %s", async (baseUrl) => { + let calls = 0; + const result = await probeCore({ + baseUrl, + token: "must-not-leave-browser", + fetch: (async () => { + calls += 1; + throw new Error("must not request an unsafe URL"); + }) as typeof fetch, + }); + + expect(result).toEqual({ kind: "invalid_configuration", executionReadiness: "unknown" }); + expect(calls).toBe(0); + }); + + it("times out one stalled request without retrying", async () => { + let calls = 0; + const result = await probeCore({ + baseUrl: "/v1", + timeoutMs: 10, + fetch: (async (_input, init) => { + calls += 1; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }) as typeof fetch, + }); + + expect(result).toEqual({ kind: "unreachable", executionReadiness: "unknown" }); + expect(calls).toBe(1); + }); + + it.each([200, 503])("times out a stalled response body after HTTP %s headers without retrying", async (status) => { + let calls = 0; + const result = await probeCore({ + baseUrl: "/v1", + timeoutMs: 10, + fetch: (async (_input, init) => { + calls += 1; + const signal = init?.signal; + if (!signal) throw new Error("probe signal missing"); + return stalledBodyResponse(status, signal); + }) as typeof fetch, + }); + + expect(result).toEqual({ kind: "unreachable", executionReadiness: "unknown" }); + expect(calls).toBe(1); + }); + + it("passes through a caller abort while a response body is stalled", async () => { + const controller = new AbortController(); + let calls = 0; + const pending = probeCore({ + baseUrl: "/v1", + signal: controller.signal, + timeoutMs: 1_000, + fetch: (async (_input, init) => { + calls += 1; + const signal = init?.signal; + if (!signal) throw new Error("probe signal missing"); + return stalledBodyResponse(200, signal); + }) as typeof fetch, + }); + const abortTimer = globalThis.setTimeout(() => controller.abort(), 0); + + try { + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + } finally { + globalThis.clearTimeout(abortTimer); + } + expect(calls).toBe(1); + }); + + it("passes through aborts so stale probes can be fenced", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + probeCore({ + baseUrl: "/v1", + signal: controller.signal, + fetch: (async (_input, init) => { + expect(init?.signal?.aborted).toBe(true); + throw init?.signal?.reason ?? new DOMException("Aborted", "AbortError"); + }) as typeof fetch, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +describe("Core probe URL validation", () => { + it("normalizes the probe path without putting credentials in the URL", () => { + expect(coreProbeUrl("https://core.example/v1///")).toBe("https://core.example/v1/agents?limit=1"); + }); + + it.each([ + "https://core.example/v1", + "http://127.0.0.1:8091/v1/", + "https://core.example/v1%3Ftenant%3Dsafe", + "https://core.example/v1%23section", + ])("accepts an explicit HTTP(S) direct Core URL", (value) => { + expect(isValidDirectCoreBaseUrl(value)).toBe(true); + }); + + it.each([ + "", + "/v1", + "ftp://core.example/v1", + "https://user:secret@core.example/v1", + "https://@core.example/v1", + "https://core.example/v1?", + "https://core.example/v1#", + "https://core.example/v1?token=secret", + "https://core.example/v1#secret", + ])("rejects an unsafe or non-direct Core URL", (value) => { + expect(isValidDirectCoreBaseUrl(value)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/core-probe.ts b/apps/web/src/lib/core-probe.ts new file mode 100644 index 0000000..ffce4d4 --- /dev/null +++ b/apps/web/src/lib/core-probe.ts @@ -0,0 +1,220 @@ +import { isLocalProxyBaseUrl, isValidDirectCoreBaseUrl } from "./connection"; + +export type CoreProbeKind = + | "authenticated" + | "invalid_configuration" + | "unauthorized" + | "protocol_mismatch" + | "http_error" + | "unreachable"; + +export interface CoreProbeResult { + kind: CoreProbeKind; + executionReadiness: "unknown"; + httpStatus?: number; +} + +export interface CoreProbeOptions { + baseUrl: string; + token?: string; + signal?: AbortSignal; + fetch?: typeof fetch; + timeoutMs?: number; +} + +function trimTrailingSlash(value: string): string { + return (value.trim() || "/v1").replace(/\/+$/, ""); +} + +export function coreProbeUrl(baseUrl: string): string { + return `${trimTrailingSlash(baseUrl)}/agents?limit=1`; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isStringRecord(value: unknown): boolean { + return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); +} + +function isCanonicalReasoning(value: unknown): boolean { + if (!isRecord(value)) return false; + const efforts = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); + const summaries = new Set(["concise", "detailed", "auto"]); + return ( + (value.effort === undefined || value.effort === null || (typeof value.effort === "string" && efforts.has(value.effort))) && + (value.summary === undefined || value.summary === null || (typeof value.summary === "string" && summaries.has(value.summary))) + ); +} + +function isCanonicalText(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value.format)) return false; + const format = value.format; + const validFormat = format.type === "text" || (format.type === "json_schema" && isRecord(format.schema)); + return validFormat && typeof value.verbosity === "string" && ["low", "medium", "high"].includes(value.verbosity); +} + +function isCanonicalMultiAgent(value: unknown): boolean { + if (!isRecord(value) || typeof value.enabled !== "boolean") return false; + if (!value.enabled) return value.max_concurrent_subagents === null; + return ( + Number.isSafeInteger(value.max_concurrent_subagents) && + Number(value.max_concurrent_subagents) > 0 && + Number(value.max_concurrent_subagents) <= 4_294_967_295 + ); +} + +function isCanonicalSavedAgentTool(value: unknown): boolean { + return isRecord(value) && typeof value.type === "string" && value.type.length > 0; +} + +function isCanonicalSavedAgent(value: unknown): value is Record & { id: string } { + if (!isRecord(value)) return false; + return ( + typeof value.id === "string" && value.id.length > 0 && + value.object === "agent" && + typeof value.model === "string" && + isNullableString(value.name) && + isNullableString(value.instructions) && + isStringRecord(value.metadata) && + isCanonicalMultiAgent(value.multi_agent) && + isCanonicalReasoning(value.reasoning) && + typeof value.service_tier === "string" && + ["auto", "default", "flex", "priority", "fast"].includes(value.service_tier) && + isCanonicalText(value.text) && + Array.isArray(value.tools) && value.tools.every(isCanonicalSavedAgentTool) && + Number.isSafeInteger(value.created_at) && + Number.isSafeInteger(value.updated_at) + ); +} + +function isAgentListPage(value: unknown): boolean { + if (!isRecord(value) || value.object !== "list" || !Array.isArray(value.data)) return false; + if (typeof value.has_more !== "boolean" || !isNullableString(value.first_id) || !isNullableString(value.last_id)) { + return false; + } + if (!value.data.every(isCanonicalSavedAgent)) return false; + if (value.data.length === 0) return value.first_id === null && value.last_id === null && !value.has_more; + return value.first_id === value.data[0]?.id && value.last_id === value.data.at(-1)?.id; +} + +async function readErrorCode(response: Response): Promise { + const envelope: unknown = await response.json(); + if (!isRecord(envelope) || !isRecord(envelope.error)) return undefined; + return typeof envelope.error.code === "string" ? envelope.error.code : undefined; +} + +function classifyBodyReadFailure( + error: unknown, + callerSignal: AbortSignal | undefined, + deadlineSignal: AbortSignal, +): "invalid_json" | "unreachable" { + if (callerSignal?.aborted) throw error; + if (deadlineSignal.aborted) return "unreachable"; + return error instanceof SyntaxError ? "invalid_json" : "unreachable"; +} + +function probeSignal(callerSignal: AbortSignal | undefined, timeoutMs: number) { + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(callerSignal?.reason); + if (callerSignal?.aborted) abortFromCaller(); + else callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); + const timer = globalThis.setTimeout( + () => controller.abort(new DOMException("Core connection probe timed out", "TimeoutError")), + timeoutMs, + ); + return { + signal: controller.signal, + dispose() { + globalThis.clearTimeout(timer); + callerSignal?.removeEventListener("abort", abortFromCaller); + }, + }; +} + +export async function probeCore(options: CoreProbeOptions): Promise { + const localProxy = isLocalProxyBaseUrl(options.baseUrl); + if (!localProxy && !isValidDirectCoreBaseUrl(options.baseUrl)) { + return { kind: "invalid_configuration", executionReadiness: "unknown" }; + } + const timeoutMs = options.timeoutMs ?? 5_000; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return { kind: "invalid_configuration", executionReadiness: "unknown" }; + } + const headers = new Headers({ + Accept: "application/json", + "OpenAI-Beta": "agents=v1", + }); + const token = localProxy ? undefined : options.token?.trim(); + if (token) headers.set("Authorization", `Bearer ${token}`); + const deadline = probeSignal(options.signal, timeoutMs); + + try { + const response = await (options.fetch ?? globalThis.fetch.bind(globalThis))(coreProbeUrl(options.baseUrl), { + method: "GET", + headers, + signal: deadline.signal, + cache: "no-store", + }); + + if (response.status === 200) { + let payload: unknown; + try { + payload = await response.json(); + } catch (error) { + if (classifyBodyReadFailure(error, options.signal, deadline.signal) === "unreachable") { + return { kind: "unreachable", executionReadiness: "unknown" }; + } + return { + kind: "protocol_mismatch", + executionReadiness: "unknown", + httpStatus: response.status, + }; + } + + return { + kind: isAgentListPage(payload) ? "authenticated" : "protocol_mismatch", + executionReadiness: "unknown", + httpStatus: response.status, + }; + } + + if (response.ok) { + return { + kind: "protocol_mismatch", + executionReadiness: "unknown", + httpStatus: response.status, + }; + } + + let errorCode: string | undefined; + try { + errorCode = await readErrorCode(response); + } catch (error) { + if (classifyBodyReadFailure(error, options.signal, deadline.signal) === "unreachable") { + return { kind: "unreachable", executionReadiness: "unknown" }; + } + } + if (response.status === 401 && errorCode === "invalid_api_key") { + return { kind: "unauthorized", executionReadiness: "unknown", httpStatus: response.status }; + } + if ( + (response.status === 400 && errorCode === "invalid_beta_header") || + response.status === 404 || + response.status === 405 + ) { + return { kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: response.status }; + } + return { kind: "http_error", executionReadiness: "unknown", httpStatus: response.status }; + } catch (error) { + if (options.signal?.aborted) throw error; + return { kind: "unreachable", executionReadiness: "unknown" }; + } finally { + deadline.dispose(); + } +}