Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions admin/src/dashboard-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<T>(baseUrl: string, path: string, init: RequestInit = {}): Promise<T> {
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<T>;
}

export async function playgroundRequest(
baseUrl: string,
path: string,
init: RequestInit = {},
timeoutMs = PLAYGROUND_FETCH_TIMEOUT_MS,
): Promise<PlaygroundHttpResponse> {
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);
}
35 changes: 1 addition & 34 deletions admin/src/ui-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,36 +25,7 @@ export async function settled<T>(loader: () => Promise<T>): Promise<{ ok: true;
}
}

export async function request<T>(baseUrl: string, path: string, init: RequestInit = {}): Promise<T> {
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<T>;
}

export async function playgroundRequest(baseUrl: string, path: string, init: RequestInit = {}): Promise<PlaygroundHttpResponse> {
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<PlaygroundTurn, "id" | "response" | "rawResponse"> & { raw: string }): PlaygroundTurn {
return {
Expand All @@ -74,10 +45,6 @@ export function createPlaygroundTurn(input: Omit<PlaygroundTurn, "id" | "respons
};
}

export function isTextualResponse(contentType: string) {
return /(^text\/|json|xml|html|csv|yaml|graphql|javascript)/i.test(contentType);
}

export function readyCount(services: ServiceItem[]) {
return services.filter((service) => service.readiness?.executable).length;
}
Expand Down
77 changes: 77 additions & 0 deletions admin/test/dashboard-fetch.test.mjs
Original file line number Diff line number Diff line change
@@ -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]);
});
8 changes: 8 additions & 0 deletions shared/fetch-timeout.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 7 additions & 1 deletion worker/oauth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -44,7 +45,12 @@ export async function oauthCallback(request: Request, env: Env): Promise<Respons
const secret = envString(env, config.clientSecretConfig); if (!secret) return errorResponse("oauth_not_configured", "provider OAuth client secret is not configured", 503); form.set("client_secret", secret);
}
for (const [key, value] of Object.entries(config.extraTokenParams)) form.set(key, value);
const tokenResponse = await fetch(config.tokenUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: form });
let tokenResponse: Response;
try {
tokenResponse = await fetch(config.tokenUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: form, signal: fetchTimeoutSignal(request.signal) });
} catch {
return callbackPage(false, "Provider token exchange failed.");
}
const payload: Record<string, unknown> = await tokenResponse.json<Record<string, unknown>>().catch(() => ({}));
if (!tokenResponse.ok || typeof payload.access_token !== "string") return callbackPage(false, "Provider token exchange failed.");
const existing = await env.POLICY_KV.get<UpstreamGrant>(state.grantKey, "json");
Expand Down
29 changes: 29 additions & 0 deletions worker/test/fetch-timeout.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
});
46 changes: 46 additions & 0 deletions worker/test/oauth-token-fetch.mocks.mjs
Original file line number Diff line number Diff line change
@@ -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() {}
54 changes: 54 additions & 0 deletions worker/test/oauth-token-fetch.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});