Skip to content

Commit d09eb43

Browse files
committed
fix(dev): bound Claude usage debug fetches
1 parent 5fdaf6b commit d09eb43

2 files changed

Lines changed: 125 additions & 16 deletions

File tree

scripts/debug-claude-usage.ts

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,26 @@ import os from "node:os";
55
import path from "node:path";
66
import { pathToFileURL } from "node:url";
77
import { normalizeOptionalString } from "../src/shared/string-coerce.ts";
8-
import { maskIdentifier, previewForDevToolLog, redactHomePath } from "./lib/dev-tooling-safety.ts";
8+
import {
9+
maskIdentifier,
10+
parseStrictIntegerOption,
11+
previewForDevToolLog,
12+
redactHomePath,
13+
} from "./lib/dev-tooling-safety.ts";
914

1015
type Args = {
1116
agentId: string;
1217
reveal: boolean;
1318
sessionKey?: string;
1419
};
1520

21+
type FetchOptions = {
22+
fetchImpl?: typeof fetch;
23+
timeoutMs?: number;
24+
};
25+
26+
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
27+
1628
const mask = (value: string) => {
1729
return maskIdentifier(
1830
value,
@@ -80,17 +92,68 @@ const pickAnthropicTokens = (store: {
8092
return found;
8193
};
8294

83-
const fetchAnthropicOAuthUsage = async (token: string) => {
84-
const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
85-
headers: {
86-
Authorization: `Bearer ${token}`,
87-
Accept: "application/json",
88-
"anthropic-version": "2023-06-01",
89-
"anthropic-beta": "oauth-2025-04-20",
90-
"User-Agent": "openclaw-debug",
91-
},
95+
const resolveFetchTimeoutMs = (raw = process.env.OPENCLAW_DEBUG_CLAUDE_USAGE_FETCH_TIMEOUT_MS) => {
96+
return parseStrictIntegerOption({
97+
fallback: DEFAULT_FETCH_TIMEOUT_MS,
98+
label: "OPENCLAW_DEBUG_CLAUDE_USAGE_FETCH_TIMEOUT_MS",
99+
min: 1,
100+
raw,
101+
});
102+
};
103+
104+
const withFetchTimeout = async <T>(
105+
label: string,
106+
timeoutMs: number,
107+
run: (signal: AbortSignal) => Promise<T>,
108+
): Promise<T> => {
109+
const controller = new AbortController();
110+
let timeout: ReturnType<typeof setTimeout> | undefined;
111+
const timeoutPromise = new Promise<T>((_resolve, reject) => {
112+
timeout = setTimeout(() => {
113+
const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`);
114+
reject(error);
115+
controller.abort(error);
116+
}, timeoutMs);
92117
});
93-
const text = await res.text();
118+
try {
119+
return await Promise.race([run(controller.signal), timeoutPromise]);
120+
} finally {
121+
if (timeout) {
122+
clearTimeout(timeout);
123+
}
124+
}
125+
};
126+
127+
const fetchText = async (
128+
label: string,
129+
url: string,
130+
init: RequestInit,
131+
options: FetchOptions = {},
132+
) => {
133+
const fetchImpl = options.fetchImpl ?? fetch;
134+
const timeoutMs = options.timeoutMs ?? resolveFetchTimeoutMs();
135+
return await withFetchTimeout(label, timeoutMs, async (signal) => {
136+
const res = await fetchImpl(url, { ...init, signal });
137+
const text = await res.text();
138+
return { res, text };
139+
});
140+
};
141+
142+
const fetchAnthropicOAuthUsage = async (token: string, options: FetchOptions = {}) => {
143+
const { res, text } = await fetchText(
144+
"Anthropic OAuth usage request",
145+
"https://api.anthropic.com/api/oauth/usage",
146+
{
147+
headers: {
148+
Authorization: `Bearer ${token}`,
149+
Accept: "application/json",
150+
"anthropic-version": "2023-06-01",
151+
"anthropic-beta": "oauth-2025-04-20",
152+
"User-Agent": "openclaw-debug",
153+
},
154+
},
155+
options,
156+
);
94157
return { status: res.status, contentType: res.headers.get("content-type"), text };
95158
};
96159

@@ -303,15 +366,19 @@ const findClaudeSessionKey = (): { sessionKey: string; source: string } | null =
303366
return null;
304367
};
305368

306-
const fetchClaudeWebUsage = async (sessionKey: string) => {
369+
const fetchClaudeWebUsage = async (sessionKey: string, options: FetchOptions = {}) => {
307370
const headers = {
308371
Cookie: `sessionKey=${sessionKey}`,
309372
Accept: "application/json",
310373
"User-Agent":
311374
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
312375
};
313-
const orgRes = await fetch("https://claude.ai/api/organizations", { headers });
314-
const orgText = await orgRes.text();
376+
const { res: orgRes, text: orgText } = await fetchText(
377+
"Claude organizations request",
378+
"https://claude.ai/api/organizations",
379+
{ headers },
380+
options,
381+
);
315382
if (!orgRes.ok) {
316383
return { ok: false as const, step: "organizations", status: orgRes.status, body: orgText };
317384
}
@@ -321,8 +388,12 @@ const fetchClaudeWebUsage = async (sessionKey: string) => {
321388
return { ok: false as const, step: "organizations", status: 200, body: orgText };
322389
}
323390

324-
const usageRes = await fetch(`https://claude.ai/api/organizations/${orgId}/usage`, { headers });
325-
const usageText = await usageRes.text();
391+
const { res: usageRes, text: usageText } = await fetchText(
392+
"Claude usage request",
393+
`https://claude.ai/api/organizations/${orgId}/usage`,
394+
{ headers },
395+
options,
396+
);
326397
return usageRes.ok
327398
? { ok: true as const, orgId, body: usageText }
328399
: { ok: false as const, step: "usage", status: usageRes.status, body: usageText };
@@ -397,7 +468,9 @@ export const testing = {
397468
CLAUDE_COOKIE_HOST_SQL,
398469
CLAUDE_FIREFOX_COOKIE_HOST_SQL,
399470
browserRootLabel,
471+
fetchAnthropicOAuthUsage,
400472
mask,
473+
resolveFetchTimeoutMs,
401474
};
402475

403476
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

test/scripts/dev-tooling-safety.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,40 @@ describe("script-specific dev tooling hardening", () => {
204204
expect(claudeUsageTesting.CLAUDE_COOKIE_HOST_SQL).toContain("LIKE '%.claude.ai'");
205205
expect(claudeUsageTesting.CLAUDE_COOKIE_HOST_SQL).not.toContain("%claude.ai%");
206206
});
207+
208+
it("aborts stalled Claude usage fetches at the request timeout", async () => {
209+
let signal: AbortSignal | undefined;
210+
const request = claudeUsageTesting.fetchAnthropicOAuthUsage("test-token", {
211+
timeoutMs: 5,
212+
fetchImpl: ((_url, init) => {
213+
signal = init?.signal ?? undefined;
214+
return new Promise(() => {});
215+
}) as typeof fetch,
216+
});
217+
218+
await expect(request).rejects.toThrow(/Anthropic OAuth usage request exceeded timeout/u);
219+
expect(signal?.aborted).toBe(true);
220+
});
221+
222+
it("times out stalled Claude usage response body reads", async () => {
223+
const response = {
224+
ok: true,
225+
status: 200,
226+
headers: new Headers({ "content-type": "application/json" }),
227+
text: () => new Promise(() => {}),
228+
} as Response;
229+
const request = claudeUsageTesting.fetchAnthropicOAuthUsage("test-token", {
230+
timeoutMs: 5,
231+
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
232+
});
233+
234+
await expect(request).rejects.toThrow(/Anthropic OAuth usage request exceeded timeout/u);
235+
});
236+
237+
it("rejects invalid Claude usage timeout values", () => {
238+
expect(claudeUsageTesting.resolveFetchTimeoutMs("123")).toBe(123);
239+
expect(() => claudeUsageTesting.resolveFetchTimeoutMs("1.5")).toThrow(
240+
/OPENCLAW_DEBUG_CLAUDE_USAGE_FETCH_TIMEOUT_MS must be an integer/u,
241+
);
242+
});
207243
});

0 commit comments

Comments
 (0)