diff --git a/admin/src/dashboard-fetch.ts b/admin/src/dashboard-fetch.ts new file mode 100644 index 0000000..676c3b2 --- /dev/null +++ b/admin/src/dashboard-fetch.ts @@ -0,0 +1,47 @@ +import { DASHBOARD_FETCH_TIMEOUT_MS, fetchTimeoutSignal, PLAYGROUND_FETCH_TIMEOUT_MS } from "../../shared/fetch-timeout"; +import type { PlaygroundHttpResponse } from "./ui-types"; + +export async function request(baseUrl: string, path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { ...init, credentials: "same-origin", headers, signal: fetchTimeoutSignal(init.signal, DASHBOARD_FETCH_TIMEOUT_MS) }); + if (!response.ok) throw new Error((await response.text()) || `${path} failed with ${response.status}`); + if (!(response.headers.get("content-type") ?? "").includes("application/json")) throw new Error(`${path} returned a non-JSON response from ${baseUrl}`); + return response.json() as Promise; +} + +export async function playgroundRequest( + baseUrl: string, + path: string, + init: RequestInit = {}, + timeoutMs = PLAYGROUND_FETCH_TIMEOUT_MS, +): Promise { + const headers = new Headers(init.headers); + const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { + ...init, + credentials: "same-origin", + headers, + signal: fetchTimeoutSignal(init.signal, timeoutMs), + }); + const contentType = response.headers.get("content-type") ?? ""; + const retention = response.headers.get("x-clawrouter-content-retention") ?? "unknown"; + const body = await response.arrayBuffer(); + const text = isTextualResponse(contentType) ? new TextDecoder().decode(body) : ""; + let raw = ""; + if (response.status === 204 || body.byteLength === 0) raw = `HTTP ${response.status} ${response.statusText || "No Content"}`.trim(); + if (contentType.includes("application/json") || contentType.includes("+json")) { + try { + raw = JSON.stringify(JSON.parse(text), null, 2); + } catch { + raw = text; + } + } else if (text) { + raw = text; + } else if (!raw) { + raw = `HTTP ${response.status} ${response.statusText || "OK"}\n${contentType || "binary"} response (${body.byteLength} bytes)`; + } + return { ok: response.ok, raw, status: response.status, statusText: response.statusText, contentType, retention }; +} + +export function isTextualResponse(contentType: string) { + return /(^text\/|json|xml|html|csv|yaml|graphql|javascript)/i.test(contentType); +} diff --git a/admin/src/ui-helpers.ts b/admin/src/ui-helpers.ts index 438a265..33cb335 100644 --- a/admin/src/ui-helpers.ts +++ b/admin/src/ui-helpers.ts @@ -25,36 +25,7 @@ export async function settled(loader: () => Promise): Promise<{ ok: true; } } -export async function request(baseUrl: string, path: string, init: RequestInit = {}): Promise { - const headers = new Headers(init.headers); - const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { ...init, credentials: "same-origin", headers }); - if (!response.ok) throw new Error((await response.text()) || `${path} failed with ${response.status}`); - if (!(response.headers.get("content-type") ?? "").includes("application/json")) throw new Error(`${path} returned a non-JSON response from ${baseUrl}`); - return response.json() as Promise; -} - -export async function playgroundRequest(baseUrl: string, path: string, init: RequestInit = {}): Promise { - const headers = new Headers(init.headers); - const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { ...init, credentials: "same-origin", headers }); - const contentType = response.headers.get("content-type") ?? ""; - const retention = response.headers.get("x-clawrouter-content-retention") ?? "unknown"; - const body = await response.arrayBuffer(); - const text = isTextualResponse(contentType) ? new TextDecoder().decode(body) : ""; - let raw = ""; - if (response.status === 204 || body.byteLength === 0) raw = `HTTP ${response.status} ${response.statusText || "No Content"}`.trim(); - if (contentType.includes("application/json") || contentType.includes("+json")) { - try { - raw = JSON.stringify(JSON.parse(text), null, 2); - } catch { - raw = text; - } - } else if (text) { - raw = text; - } else if (!raw) { - raw = `HTTP ${response.status} ${response.statusText || "OK"}\n${contentType || "binary"} response (${body.byteLength} bytes)`; - } - return { ok: response.ok, raw, status: response.status, statusText: response.statusText, contentType, retention }; -} +export { isTextualResponse, playgroundRequest, request } from "./dashboard-fetch"; export function createPlaygroundTurn(input: Omit & { raw: string }): PlaygroundTurn { return { @@ -74,10 +45,6 @@ export function createPlaygroundTurn(input: Omit service.readiness?.executable).length; } diff --git a/admin/test/dashboard-fetch.test.mjs b/admin/test/dashboard-fetch.test.mjs new file mode 100644 index 0000000..e137f5f --- /dev/null +++ b/admin/test/dashboard-fetch.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { extname } from "node:path"; +import { registerHooks } from "node:module"; +import test from "node:test"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith(".") && context.parentURL && !extname(new URL(specifier, context.parentURL).pathname)) { + return nextResolve(`${specifier}.ts`, context); + } + return nextResolve(specifier, context); + }, +}); + +const { playgroundRequest, request } = await import("../src/dashboard-fetch.ts"); + +test("dashboard JSON request leaves headroom for a typed 30s Worker timeout and keeps a caller signal", async (context) => { + const timeouts = []; + const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); + context.mock.method(AbortSignal, "timeout", (ms) => { + timeouts.push(ms); + return nativeTimeout(ms); + }); + const seen = []; + context.mock.method(globalThis, "fetch", async (_input, init) => { + seen.push(init); + return Response.json({ ok: true }); + }); + + await request("https://console.example", "/v1/session"); + assert.equal(seen.length, 1); + assert.equal(seen[0].credentials, "same-origin"); + assert.ok(seen[0].signal instanceof AbortSignal); + assert.equal(seen[0].signal.aborted, false); + assert.deepEqual(timeouts, [60_000]); + + const caller = new AbortController(); + await request("https://console.example", "/v1/me", { signal: caller.signal }); + assert.equal(seen.length, 2); + assert.notEqual(seen[1].signal, caller.signal); + assert.ok(seen[1].signal instanceof AbortSignal); + assert.deepEqual(timeouts, [60_000, 60_000]); + caller.abort(); + assert.equal(seen[1].signal.aborted, true); +}); + +test("playground request uses the 600s endpoint budget instead of the bounded dashboard timeout", async (context) => { + const timeouts = []; + const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); + context.mock.method(AbortSignal, "timeout", (ms) => { + timeouts.push(ms); + return nativeTimeout(ms); + }); + let init; + context.mock.method(globalThis, "fetch", async (_input, options) => { + init = options; + return new Response("ok", { status: 200, headers: { "content-type": "text/plain" } }); + }); + + const result = await playgroundRequest("https://console.example/", "/v1/chat/completions"); + assert.equal(result.status, 200); + assert.ok(init.signal instanceof AbortSignal); + assert.deepEqual(timeouts, [600_000]); +}); + +test("playground request honors an explicit endpoint timeout", async (context) => { + const timeouts = []; + const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); + context.mock.method(AbortSignal, "timeout", (ms) => { + timeouts.push(ms); + return nativeTimeout(ms); + }); + context.mock.method(globalThis, "fetch", async () => new Response("ok", { status: 200, headers: { "content-type": "text/plain" } })); + + await playgroundRequest("https://console.example/", "/v1/proxy/openai/chat", {}, 180_000); + assert.deepEqual(timeouts, [180_000]); +}); diff --git a/shared/fetch-timeout.ts b/shared/fetch-timeout.ts new file mode 100644 index 0000000..38e0b76 --- /dev/null +++ b/shared/fetch-timeout.ts @@ -0,0 +1,8 @@ +export const DEFAULT_FETCH_TIMEOUT_MS = 30_000; +export const DASHBOARD_FETCH_TIMEOUT_MS = 60_000; +export const PLAYGROUND_FETCH_TIMEOUT_MS = 600_000; + +export function fetchTimeoutSignal(existing?: AbortSignal | null, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs); + return existing ? AbortSignal.any([existing, timeout]) : timeout; +} diff --git a/worker/oauth.ts b/worker/oauth.ts index afb50cf..acd8e77 100644 --- a/worker/oauth.ts +++ b/worker/oauth.ts @@ -1,3 +1,4 @@ +import { fetchTimeoutSignal } from "../shared/fetch-timeout.ts"; import { authorizeAdmin, verifiedAccessSession } from "./access"; import { authorityCall } from "./authority"; import { syncGrantPoolIndex } from "./grant-selection"; @@ -44,7 +45,12 @@ export async function oauthCallback(request: Request, env: Env): Promise = await tokenResponse.json>().catch(() => ({})); if (!tokenResponse.ok || typeof payload.access_token !== "string") return callbackPage(false, "Provider token exchange failed."); const existing = await env.POLICY_KV.get(state.grantKey, "json"); diff --git a/worker/test/fetch-timeout.test.mjs b/worker/test/fetch-timeout.test.mjs new file mode 100644 index 0000000..7908e40 --- /dev/null +++ b/worker/test/fetch-timeout.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; +import { DEFAULT_FETCH_TIMEOUT_MS, fetchTimeoutSignal } from "../../shared/fetch-timeout.ts"; + +test("fetchTimeoutSignal defaults to 30s and combines a caller abort", () => { + assert.equal(DEFAULT_FETCH_TIMEOUT_MS, 30_000); + const caller = new AbortController(); + const combined = fetchTimeoutSignal(caller.signal, 5_000); + assert.ok(combined instanceof AbortSignal); + assert.notEqual(combined, caller.signal); + assert.equal(combined.aborted, false); + caller.abort(); + assert.equal(combined.aborted, true); +}); + +test("fetchTimeoutSignal aborts a TCP accept that never sends an HTTP response", async () => { + const server = createServer(() => {}); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + try { + await assert.rejects( + fetch(`http://127.0.0.1:${port}/hung`, { signal: fetchTimeoutSignal(undefined, 40) }), + (error) => error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError"), + ); + } finally { + server.close(); + } +}); diff --git a/worker/test/oauth-token-fetch.mocks.mjs b/worker/test/oauth-token-fetch.mocks.mjs new file mode 100644 index 0000000..6325a02 --- /dev/null +++ b/worker/test/oauth-token-fetch.mocks.mjs @@ -0,0 +1,46 @@ +export async function verifiedAccessSession() { + return { role: "admin", email: "admin@example.com" }; +} + +export async function authorizeAdmin() { + return { email: "admin@example.com" }; +} + +export async function authorityCall() { + return { + state: { + state: "state-1", + verifier: "verifier", + actorEmail: "admin@example.com", + grantKey: "oauth/policy/openai", + provider: "openai", + priority: 100, + weight: 1, + redirectUri: "https://console.example/v1/oauth/callback", + expiresAtMs: Date.now() + 60_000, + }, + }; +} + +export function providerById(id) { + if (id !== "openai") return null; + return { + id: "openai", + display_name: "OpenAI", + auth: { + authorization: { + tokenUrl: "https://token.example/oauth/token", + clientId: "client", + clientIdConfig: null, + clientSecretConfig: null, + scopes: ["openid"], + extraTokenParams: {}, + grantKind: "oauth", + accountIdJsonPointer: null, + subscriptionPlanJsonPointer: null, + }, + }, + }; +} + +export async function syncGrantPoolIndex() {} diff --git a/worker/test/oauth-token-fetch.test.mjs b/worker/test/oauth-token-fetch.test.mjs new file mode 100644 index 0000000..1f51c85 --- /dev/null +++ b/worker/test/oauth-token-fetch.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { extname } from "node:path"; +import { pathToFileURL } from "node:url"; +import { registerHooks } from "node:module"; +import test from "node:test"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL === pathToFileURL(new URL("../oauth.ts", import.meta.url).pathname).href) { + if (specifier === "./access" || specifier === "./authority" || specifier === "./providers" || specifier === "./grant-selection") { + return { shortCircuit: true, url: new URL("./oauth-token-fetch.mocks.mjs", import.meta.url).href }; + } + } + if (specifier.startsWith(".") && context.parentURL && !extname(new URL(specifier, context.parentURL).pathname)) { + return nextResolve(`${specifier}.ts`, context); + } + return nextResolve(specifier, context); + }, +}); + +const { oauthCallback } = await import("../oauth.ts"); + +test("OAuth token exchange aborts a hung tokenUrl instead of stalling the callback", async (context) => { + const timeouts = []; + const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); + context.mock.method(AbortSignal, "timeout", (ms) => { + timeouts.push(ms); + return nativeTimeout(ms); + }); + let tokenInit; + context.mock.method(globalThis, "fetch", async (input, init) => { + assert.equal(String(input), "https://token.example/oauth/token"); + tokenInit = init; + return Response.json({ error: "temporarily_unavailable" }, { status: 504 }); + }); + + const response = await oauthCallback(new Request("https://console.example/v1/oauth/callback?state=state-1&code=auth-code"), {}); + assert.equal(response.status, 400); + assert.match(await response.text(), /Provider token exchange failed/); + assert.equal(tokenInit.method, "POST"); + assert.ok(tokenInit.signal instanceof AbortSignal); + assert.equal(tokenInit.signal.aborted, false); + assert.deepEqual(timeouts, [30_000]); +}); + +test("OAuth token timeout returns the connection-failed page instead of throwing", async (context) => { + context.mock.method(globalThis, "fetch", async () => { + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }); + + const response = await oauthCallback(new Request("https://console.example/v1/oauth/callback?state=state-1&code=auth-code"), {}); + assert.equal(response.status, 400); + assert.match(await response.text(), /Provider token exchange failed/); +});