diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 14:45:27 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 14:45:27 +0900 |
| commit | 58b985b9c82c7c1acb98ac8799b8409d5c403642 (patch) | |
| tree | dcc872cf47650a10dae8f9bf4bcd03bebf6c4bf5 /packages | |
| parent | 7c527b4d8a72159954405e720d5bf776802dc0ff (diff) | |
| download | dispatch-58b985b9c82c7c1acb98ac8799b8409d5c403642.tar.gz dispatch-58b985b9c82c7c1acb98ac8799b8409d5c403642.zip | |
fix(wake): probe with genuine Claude Code request shape so OAuth wakes succeed
The wake probe POSTed a bare { model, messages } body with no system[]
identity. Anthropic validates system[] on OAuth (Pro/Max) subscription
requests and rejects any that lack the verbatim Claude Code identity, so
every scheduled wake (and the manual Wake-now button) failed silently —
surfacing as a blank '— failed' status that then burned the retry budget.
- Add pure buildWakeProbeBody(model) in @dispatch/core mirroring a genuine
Claude Code request (billing header block + identity block + 'hi'), with
a unit test for its shape.
- wakeAllClaudeAccounts now sends that body plus the CLI session/request-id
headers, and records 'HTTP <status>: <message>' on failure so the panel
never shows a bare 'failed' and breakage stays debuggable.
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/api/src/routes/models.ts | 64 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 38 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/tests/credentials/wake-probe.test.ts | 49 |
4 files changed, 146 insertions, 6 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 6a0f5dc..8f64bbb 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,8 +1,10 @@ +import { randomUUID } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import type { ModelRegistry } from "@dispatch/core"; import { ANTHROPIC_MODELS_FALLBACK, + buildWakeProbeBody, type ClaudeAccount, fetchAnthropicModels, fetchCopilotUsage, @@ -566,6 +568,39 @@ modelsRoutes.post("/remove-key", async (c) => { // ─── Shared wake function ───────────────────────────────────── +/** + * Model used for the wake probe. A small/cheap model is enough — the only + * purpose is to register activity against the subscription so its rate-limit + * window keeps resetting on schedule. + */ +const WAKE_PROBE_MODEL = "claude-3-5-haiku-20241022"; + +/** Max chars of upstream error body to keep in the surfaced message. */ +const MAX_ERROR_BODY_CHARS = 200; + +/** + * Turn a non-OK probe response into a short, human-readable reason. Anthropic + * returns a JSON error envelope (`{ error: { message } }`); fall back to a + * truncated raw body, then to the bare status. Never throws. + */ +async function describeFailedResponse(res: Response): Promise<string> { + let detail = ""; + try { + const text = await res.text(); + try { + const parsed = JSON.parse(text) as { error?: { message?: unknown } }; + const message = parsed?.error?.message; + detail = typeof message === "string" ? message : text; + } catch { + detail = text; + } + } catch { + detail = ""; + } + detail = detail.trim().slice(0, MAX_ERROR_BODY_CHARS); + return detail ? `HTTP ${res.status}: ${detail}` : `HTTP ${res.status}`; +} + async function wakeAllClaudeAccounts(): Promise< Array<{ label: string; ok: boolean; error?: string }> > { @@ -596,20 +631,37 @@ async function wakeAllClaudeAccounts(): Promise< continue; } + // Mirror a genuine Claude Code CLI request. These are OAuth + // (Pro/Max) subscription accounts: Anthropic validates the + // `system[]` array and rejects (401/403) any request whose system + // block lacks the verbatim Claude Code identity string. A bare + // `{ model, messages }` body — what this probe used to send — + // always failed, which is why scheduled wakes silently died with a + // blank "failed" status. `buildWakeProbeBody` produces the correct + // shape (billing header + identity); the session/request-id headers + // match what the real CLI stamps so the probe isn't flagged. const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { ...getAnthropicHeaders(creds.accessToken), "content-type": "application/json", + "X-Claude-Code-Session-Id": randomUUID(), + "x-client-request-id": randomUUID(), }, - body: JSON.stringify({ - model: "claude-3-5-haiku-20241022", - max_tokens: 16, - messages: [{ role: "user", content: "hi" }], - }), + body: JSON.stringify(buildWakeProbeBody(WAKE_PROBE_MODEL)), }); - results.push({ label: acct.label, ok: res.ok }); + if (res.ok) { + results.push({ label: acct.label, ok: true }); + } else { + // Surface WHY it failed so the panel never shows a bare + // "failed" again and breakage stays debuggable. + results.push({ + label: acct.label, + ok: false, + error: await describeFailedResponse(res), + }); + } } catch (err) { results.push({ label: acct.label, diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 168d544..432e403 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -373,6 +373,44 @@ export function buildBillingHeaderValue( export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; +/** + * Build the request body for a Claude "wake" probe — a tiny, cheap message + * whose only purpose is to keep the subscription's rate-limit window warm. + * + * This MUST mirror the shape of a genuine Claude Code CLI request, because + * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated, + * Claude-Code-billed requests. A bare `{ model, messages }` body (no system + * identity) is rejected (401/403) — which is exactly how the old probe silently + * failed. The valid shape is: + * + * system: [ + * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control + * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + * ] + * messages: [ { role: "user", content: "hi" } ] + * + * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user + * turn. Pure: deterministic given its inputs (the billing header samples only + * the user text), so it can be unit-tested without touching the network. + */ +export function buildWakeProbeBody(model: string): { + model: string; + max_tokens: number; + system: Array<{ type: "text"; text: string }>; + messages: Array<{ role: "user"; content: string }>; +} { + const messages = [{ role: "user" as const, content: "hi" }]; + return { + model, + max_tokens: 16, + system: [ + { type: "text", text: buildBillingHeaderValue(messages) }, + { type: "text", text: SYSTEM_IDENTITY }, + ], + messages, + }; +} + // ─── Anthropic Request Headers ──────────────────────────────── export function getAnthropicHeaders(accessToken: string): Record<string, string> { diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index ff7392b..46fa5b6 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -9,6 +9,7 @@ export { export { ANTHROPIC_MODELS_FALLBACK, buildBillingHeaderValue, + buildWakeProbeBody, type ClaudeAccount, type ClaudeCredentials, type ClaudeProfile, diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts new file mode 100644 index 0000000..253efec --- /dev/null +++ b/packages/core/tests/credentials/wake-probe.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +// `claude.ts` transitively imports `db/index.js`, whose top-level +// `import { Database } from "bun:sqlite"` can't resolve under vitest's Node +// runtime. Stub the db module — `buildWakeProbeBody` never touches it. +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => { + throw new Error("db not available in this test"); + }), +})); + +const { buildWakeProbeBody } = await import("../../src/credentials/claude.js"); + +const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; + +describe("buildWakeProbeBody", () => { + it("targets the requested model with a tiny token budget", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.model).toBe("claude-3-5-haiku-20241022"); + expect(body.max_tokens).toBe(16); + }); + + it("emits a Claude-Code-shaped system[]: billing first, identity second", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.system).toHaveLength(2); + + // system[0] is the billing header line (no cache_control on a probe). + expect(body.system[0]).toEqual({ + type: "text", + text: expect.stringMatching(/^x-anthropic-billing-header: /), + }); + expect(body.system[0]).not.toHaveProperty("cache_control"); + + // system[1] is the VERBATIM Claude Code identity string. Anthropic + // rejects OAuth (Pro/Max) requests whose system[] lacks this. + expect(body.system[1]).toEqual({ type: "text", text: IDENTITY }); + }); + + it("carries a single short user message", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.messages).toEqual([{ role: "user", content: "hi" }]); + }); + + it("is deterministic for a given model (pure)", () => { + const a = buildWakeProbeBody("claude-3-5-haiku-20241022"); + const b = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(a).toEqual(b); + }); +}); |
