summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:45:27 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:45:27 +0900
commit58b985b9c82c7c1acb98ac8799b8409d5c403642 (patch)
treedcc872cf47650a10dae8f9bf4bcd03bebf6c4bf5 /packages
parent7c527b4d8a72159954405e720d5bf776802dc0ff (diff)
downloaddispatch-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.ts64
-rw-r--r--packages/core/src/credentials/claude.ts38
-rw-r--r--packages/core/src/credentials/index.ts1
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts49
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);
+ });
+});