clauderipple 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.ko.md +43 -2
- package/README.md +60 -4
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +89 -1
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +497 -34
- package/dist/router/src/agents.js +50 -9
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +33 -0
- package/dist/router/src/index.js +13 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +154 -15
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +99 -1
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -44
- package/dist/ui/i18n.js +88 -4
- package/dist/ui/index.html +10 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +300 -3
- package/package.json +1 -1
|
@@ -15,6 +15,8 @@ import path from "node:path";
|
|
|
15
15
|
import { homeDir } from "../../config.js";
|
|
16
16
|
export const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
17
17
|
const PING_MS = 15_000;
|
|
18
|
+
/** How long a stream may hold its headers waiting for upstream's first item (see `handle`). Tests shorten it. */
|
|
19
|
+
export const streamHold = { ms: 5_000 };
|
|
18
20
|
/** Active quota lookup. Measured 2026-09-20: GET {base}/wham/usage → 200 JSON. The binary also
|
|
19
21
|
* carries `/api/codex/usage`, but that path answers 403 here; `wham` is the one that works. */
|
|
20
22
|
const USAGE_PATH = "/wham/usage";
|
|
@@ -24,6 +26,17 @@ const USAGE_TIMEOUT_MS = 10_000;
|
|
|
24
26
|
const MODELS_PATH = "/codex/models";
|
|
25
27
|
const MODELS_TIMEOUT_MS = 15_000;
|
|
26
28
|
const MODELS_CACHE_MS = 60 * 60 * 1000;
|
|
29
|
+
/** One image took about 30 s at low quality (2026-09-29); high quality and references take longer. */
|
|
30
|
+
const IMAGE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
31
|
+
/**
|
|
32
|
+
* The shape, asked for in words. The subscription backend accepts the tool's `size` and `quality`
|
|
33
|
+
* and ignores them — even "999x1" and "bogus" came back 200 as a 1536x1024 image (2026-09-29) —
|
|
34
|
+
* while the prompt steers it: "square composition" gave 1254x1254. So the shape is a prompt hint,
|
|
35
|
+
* and quality is the backend's to choose.
|
|
36
|
+
*/
|
|
37
|
+
export const IMAGE_ASPECTS = ["square", "landscape", "portrait"];
|
|
38
|
+
export const IMAGE_FORMATS = ["png", "jpeg", "webp"];
|
|
39
|
+
export const IMAGE_BACKGROUNDS = ["auto", "transparent", "opaque"];
|
|
27
40
|
function anthropicError(status, type, message) {
|
|
28
41
|
return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
|
|
29
42
|
}
|
|
@@ -394,6 +407,107 @@ export class ChatGptAdapter {
|
|
|
394
407
|
const prose = text.trim();
|
|
395
408
|
return prose ? { hits: selected, text: prose } : { hits: selected };
|
|
396
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* Hosted image generation, the same way `searchWeb` runs a hosted search: one Responses turn with
|
|
412
|
+
* the `image_generation` tool required. Wire measured 2026-09-29 against the live backend with a
|
|
413
|
+
* ChatGPT subscription: an `image_generation_call` output item whose `result` is the base64 image,
|
|
414
|
+
* beside the `size`, `quality` and `output_format` it chose and a `revised_prompt`; about 30 s.
|
|
415
|
+
*/
|
|
416
|
+
async generateImage(model, req, signal) {
|
|
417
|
+
const tokens = await this.anyCredential();
|
|
418
|
+
if (tokens instanceof Error)
|
|
419
|
+
throw tokens;
|
|
420
|
+
const id = crypto.randomUUID();
|
|
421
|
+
const tool = {
|
|
422
|
+
type: "image_generation",
|
|
423
|
+
output_format: req.format ?? "png",
|
|
424
|
+
...(req.background ? { background: req.background } : {}),
|
|
425
|
+
};
|
|
426
|
+
const prompt = req.aspect ? `${req.prompt}\n\nCompose it as a ${req.aspect} image.` : req.prompt;
|
|
427
|
+
const content = [{ type: "input_text", text: prompt }];
|
|
428
|
+
for (const image of req.images ?? [])
|
|
429
|
+
content.push({ type: "input_image", image_url: `data:${image.mediaType};base64,${image.data}` });
|
|
430
|
+
const body = {
|
|
431
|
+
model,
|
|
432
|
+
instructions: "Generate the requested image. Use any attached images as references.",
|
|
433
|
+
input: [{ type: "message", role: "user", content }],
|
|
434
|
+
tools: [tool],
|
|
435
|
+
tool_choice: "required",
|
|
436
|
+
reasoning: { effort: "low", summary: "auto" },
|
|
437
|
+
store: false,
|
|
438
|
+
stream: true,
|
|
439
|
+
prompt_cache_key: id,
|
|
440
|
+
client_metadata: { session_id: id, thread_id: id, turn_id: crypto.randomUUID(), "x-codex-window-id": `${id}:0` },
|
|
441
|
+
};
|
|
442
|
+
const timeout = AbortSignal.timeout(IMAGE_TIMEOUT_MS);
|
|
443
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
444
|
+
const res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
|
|
445
|
+
method: "POST",
|
|
446
|
+
headers: {
|
|
447
|
+
"content-type": "application/json",
|
|
448
|
+
accept: "text/event-stream",
|
|
449
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
450
|
+
"chatgpt-account-id": tokens.accountId,
|
|
451
|
+
"OpenAI-Beta": "responses=experimental",
|
|
452
|
+
originator: "codex_cli_rs",
|
|
453
|
+
"session-id": id,
|
|
454
|
+
"thread-id": id,
|
|
455
|
+
"x-client-request-id": id,
|
|
456
|
+
"x-codex-window-id": `${id}:0`,
|
|
457
|
+
},
|
|
458
|
+
body: JSON.stringify(body),
|
|
459
|
+
signal: requestSignal,
|
|
460
|
+
});
|
|
461
|
+
this.noteRateLimits(tokens, rateLimitsFromHeaders(res.headers));
|
|
462
|
+
if (!res.ok || !res.body) {
|
|
463
|
+
const text = await res.text().catch(() => "");
|
|
464
|
+
if (res.status === 401)
|
|
465
|
+
void this.accounts.forceRefresh(tokens.ownerId);
|
|
466
|
+
throw new Error(`ChatGPT image generation: HTTP ${res.status}${text ? ` ${redactErrorText(text, [tokens.accessToken], 200)}` : ""}`);
|
|
467
|
+
}
|
|
468
|
+
const parser = new SseParser();
|
|
469
|
+
const reader = res.body.getReader();
|
|
470
|
+
const decoder = new TextDecoder();
|
|
471
|
+
let image;
|
|
472
|
+
let text = "";
|
|
473
|
+
try {
|
|
474
|
+
for (;;) {
|
|
475
|
+
const { done, value } = await reader.read();
|
|
476
|
+
if (done)
|
|
477
|
+
break;
|
|
478
|
+
for (const event of parser.feed(decoder.decode(value, { stream: true }))) {
|
|
479
|
+
if (event.type === "response.output_text.delta")
|
|
480
|
+
text += String(event.delta ?? "");
|
|
481
|
+
if (event.type === "response.output_item.done") {
|
|
482
|
+
const item = event.item;
|
|
483
|
+
if (item?.type === "image_generation_call" && typeof item.result === "string" && item.result)
|
|
484
|
+
image = item;
|
|
485
|
+
}
|
|
486
|
+
if (event.type === "error" || event.type === "response.failed") {
|
|
487
|
+
const error = (event.error ?? event.response?.error);
|
|
488
|
+
throw new Error(`ChatGPT image generation: ${String(error?.message ?? "backend error")}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
try {
|
|
495
|
+
await reader.cancel();
|
|
496
|
+
}
|
|
497
|
+
catch { /* already closed */ }
|
|
498
|
+
}
|
|
499
|
+
// A refusal comes back as prose and no image; that prose is the only explanation there is.
|
|
500
|
+
if (!image)
|
|
501
|
+
throw new Error(`ChatGPT image generation: no image returned${text.trim() ? `: ${text.trim().slice(0, 300)}` : ""}`);
|
|
502
|
+
const str = (v) => (typeof v === "string" && v ? v : undefined);
|
|
503
|
+
return {
|
|
504
|
+
data: Buffer.from(image.result, "base64"),
|
|
505
|
+
format: str(image.output_format) ?? req.format ?? "png",
|
|
506
|
+
...(str(image.size) ? { size: str(image.size) } : {}),
|
|
507
|
+
...(str(image.quality) ? { quality: str(image.quality) } : {}),
|
|
508
|
+
...(str(image.revised_prompt) ? { revisedPrompt: str(image.revised_prompt) } : {}),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
397
511
|
/** One in-flight lookup shared by every caller (the status route and the startup refresh). */
|
|
398
512
|
rateLimitsInFlight = null;
|
|
399
513
|
/**
|
|
@@ -906,12 +1020,41 @@ export class ChatGptAdapter {
|
|
|
906
1020
|
const parser = new SseParser();
|
|
907
1021
|
let bytes = 0;
|
|
908
1022
|
let ping = null;
|
|
909
|
-
|
|
1023
|
+
// Keep early rejection as an HTTP error until an actual content block arrives.
|
|
1024
|
+
// A 200 followed by an error loses the status Claude uses for overflow recovery.
|
|
1025
|
+
const pendingEvents = [];
|
|
1026
|
+
const openStream = () => {
|
|
910
1027
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
911
|
-
|
|
1028
|
+
};
|
|
1029
|
+
const sendEvents = (events) => {
|
|
1030
|
+
if (!wantStream)
|
|
1031
|
+
return;
|
|
1032
|
+
pendingEvents.push(...events);
|
|
1033
|
+
if (!res.headersSent) {
|
|
1034
|
+
if (mapper.failure?.type === "invalid_request_error")
|
|
1035
|
+
return;
|
|
1036
|
+
if (!pendingEvents.some((e) => e.event !== "message_start"))
|
|
1037
|
+
return;
|
|
1038
|
+
openStream();
|
|
1039
|
+
}
|
|
1040
|
+
for (const ev of pendingEvents.splice(0))
|
|
912
1041
|
bytes += write(res, formatSse(ev));
|
|
1042
|
+
};
|
|
1043
|
+
// The hold waits for upstream's first item, which normally follows the request within a second
|
|
1044
|
+
// — and an overflow rejection comes before it. Should it not come, the client must still hear
|
|
1045
|
+
// something: past this deadline the stream opens with message_start and the pings keep it alive,
|
|
1046
|
+
// as they did before the hold.
|
|
1047
|
+
let holdDeadline = null;
|
|
1048
|
+
if (wantStream) {
|
|
1049
|
+
holdDeadline = setTimeout(() => {
|
|
1050
|
+
if (res.headersSent || res.writableEnded || mapper.failure)
|
|
1051
|
+
return;
|
|
1052
|
+
openStream();
|
|
1053
|
+
for (const ev of [...pendingEvents.splice(0), ...mapper.start()])
|
|
1054
|
+
bytes += write(res, formatSse(ev));
|
|
1055
|
+
}, streamHold.ms);
|
|
913
1056
|
ping = setInterval(() => {
|
|
914
|
-
if (!res.writableEnded)
|
|
1057
|
+
if (res.headersSent && !res.writableEnded)
|
|
915
1058
|
bytes += write(res, formatSse({ event: "ping", data: { type: "ping" } }));
|
|
916
1059
|
}, PING_MS);
|
|
917
1060
|
}
|
|
@@ -927,9 +1070,7 @@ export class ChatGptAdapter {
|
|
|
927
1070
|
if (mapper.rateLimits && mapper.rateLimits !== this.rateLimitsByAccount.get(credential.ownerId))
|
|
928
1071
|
this.noteRateLimits(credential, mapper.rateLimits);
|
|
929
1072
|
this.rememberInput(cacheKey, mapper.usage);
|
|
930
|
-
|
|
931
|
-
for (const o of outEvents)
|
|
932
|
-
bytes += write(res, formatSse(o));
|
|
1073
|
+
sendEvents(outEvents);
|
|
933
1074
|
if (mapper.isFinished)
|
|
934
1075
|
break;
|
|
935
1076
|
}
|
|
@@ -943,22 +1084,20 @@ export class ChatGptAdapter {
|
|
|
943
1084
|
const tail = parser.sawDone
|
|
944
1085
|
? mapper.finish()
|
|
945
1086
|
: mapper.fail(`${model}: upstream stream ended before the response completed`, "server_is_overloaded");
|
|
946
|
-
|
|
947
|
-
for (const o of tail)
|
|
948
|
-
bytes += write(res, formatSse(o));
|
|
1087
|
+
sendEvents(tail);
|
|
949
1088
|
}
|
|
950
1089
|
}
|
|
951
1090
|
catch (e) {
|
|
952
1091
|
if (!ac.signal.aborted) {
|
|
953
1092
|
const tail = mapper.fail(`stream interrupted: ${e.message}`, "server_is_overloaded");
|
|
954
|
-
|
|
955
|
-
for (const o of tail)
|
|
956
|
-
bytes += write(res, formatSse(o));
|
|
1093
|
+
sendEvents(tail);
|
|
957
1094
|
}
|
|
958
1095
|
}
|
|
959
1096
|
finally {
|
|
960
1097
|
if (ping)
|
|
961
1098
|
clearInterval(ping);
|
|
1099
|
+
if (holdDeadline)
|
|
1100
|
+
clearTimeout(holdDeadline);
|
|
962
1101
|
res.off("close", onClose);
|
|
963
1102
|
try {
|
|
964
1103
|
await reader.cancel();
|
|
@@ -968,8 +1107,8 @@ export class ChatGptAdapter {
|
|
|
968
1107
|
}
|
|
969
1108
|
}
|
|
970
1109
|
const failure = mapper.failure;
|
|
971
|
-
const failedStatus = failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
972
|
-
if (!wantStream) {
|
|
1110
|
+
const failedStatus = failure?.type === "invalid_request_error" ? 400 : failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
1111
|
+
if (!wantStream || (failure && !res.headersSent)) {
|
|
973
1112
|
// A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
|
|
974
1113
|
const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
|
|
975
1114
|
res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
|
|
@@ -985,7 +1124,7 @@ export class ChatGptAdapter {
|
|
|
985
1124
|
bytes,
|
|
986
1125
|
note: failure
|
|
987
1126
|
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
|
|
988
|
-
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}`,
|
|
1127
|
+
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}${upstreamReq.service_tier ? " fast" : ""}`,
|
|
989
1128
|
usage: {
|
|
990
1129
|
input: u.input_tokens,
|
|
991
1130
|
cached: u.cache_read_input_tokens,
|
|
@@ -161,10 +161,11 @@ export function toolNameForResponses(name) {
|
|
|
161
161
|
// worst failure shape there is. `compat.ts` has dropped them on the anthropic-compatible path from
|
|
162
162
|
// the start; the rule belongs here too, and is the same rule, not a second one.
|
|
163
163
|
//
|
|
164
|
-
//
|
|
165
|
-
// small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it
|
|
166
|
-
// `claude-haiku-4-5`, which passes through to Anthropic and never reaches an adapter. Which model
|
|
167
|
-
// that is, is a server-side flag we do not own, so this guards the day it changes.
|
|
164
|
+
// `web_search` does not arrive today. Claude Code runs its web search as a separate side request on a
|
|
165
|
+
// fixed small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it
|
|
166
|
+
// to `claude-haiku-4-5`, which passes through to Anthropic and never reaches an adapter. Which model
|
|
167
|
+
// that is, is a server-side flag we do not own, so this guards the day it changes. The `advisor`
|
|
168
|
+
// server tool does arrive: see `droppedServerToolNote`.
|
|
168
169
|
export function isServerTool(tool) {
|
|
169
170
|
const type = tool.type;
|
|
170
171
|
return type !== undefined && type !== "custom";
|
|
@@ -177,6 +178,26 @@ export function serverToolNames(tools) {
|
|
|
177
178
|
names.add(t.name);
|
|
178
179
|
return names;
|
|
179
180
|
}
|
|
181
|
+
// Dropping the declaration is half of it (issue #47). Claude Code also tells the model, in text, how
|
|
182
|
+
// to use the tool: the advisor comes with an `isMeta` user message ("# Advisor Tool / You have access
|
|
183
|
+
// to an `advisor` tool…") on every subagent, whatever its `tools:` list says (CLI 2.1.286, measured
|
|
184
|
+
// 2026-10-04: `dropped server tools for gpt-6-astra: advisor` on every worker request). A routed
|
|
185
|
+
// worker reads the instruction, finds no such tool, and says so in each report. The CLI withdraws its
|
|
186
|
+
// own instruction with one line when it removes the tool ("The advisor tool is no longer available;
|
|
187
|
+
// disregard the earlier advisor instructions."); a line does the same here, without matching wording
|
|
188
|
+
// that changes from release to release. Sorted, so one set always yields the same bytes and the
|
|
189
|
+
// cached prefix does not move.
|
|
190
|
+
/** One line withdrawing the instructions for the server tools a request had to drop; "" when none. */
|
|
191
|
+
export function droppedServerToolNote(dropped) {
|
|
192
|
+
if (dropped.size === 0)
|
|
193
|
+
return "";
|
|
194
|
+
const names = [...dropped].sort().map((n) => `\`${n}\``).join(", ");
|
|
195
|
+
return `Not available in this session: ${names}. Anthropic runs these tools on its own servers, so this model cannot call them. Disregard any instruction to use them and carry on without them; there is no need to mention that they are missing.`;
|
|
196
|
+
}
|
|
197
|
+
/** The caller's system text with that line after it, so every translator places it the same way. */
|
|
198
|
+
export function systemWithDroppedToolNote(req) {
|
|
199
|
+
return [systemText(req.system), droppedServerToolNote(serverToolNames(req.tools))].filter(Boolean).join("\n\n");
|
|
200
|
+
}
|
|
180
201
|
/**
|
|
181
202
|
* Mangled name → original, for the tools declared in this request. The model echoes the name it was
|
|
182
203
|
* given and Claude Code matches `tool_use.name` against its own tool list, so the response path has
|
|
@@ -199,7 +220,7 @@ export function toResponsesRequest(req, opts) {
|
|
|
199
220
|
// Constant per (model, effort): changing effort mid-session costs one cache miss, which is acceptable.
|
|
200
221
|
if (opts.identity)
|
|
201
222
|
parts.push(identityLine(opts.model, opts.effort));
|
|
202
|
-
const sys =
|
|
223
|
+
const sys = systemWithDroppedToolNote(req);
|
|
203
224
|
if (sys)
|
|
204
225
|
parts.push(sys);
|
|
205
226
|
if (opts.instructionsAppend)
|
|
@@ -308,6 +329,10 @@ export function toResponsesRequest(req, opts) {
|
|
|
308
329
|
}
|
|
309
330
|
if (tool_choice)
|
|
310
331
|
out.tool_choice = tool_choice;
|
|
332
|
+
// Claude Code's `/fast` arrives as `speed: "fast"` (Anthropic fast mode). The ChatGPT backend's
|
|
333
|
+
// equivalent is the Codex CLI's Fast mode, which bills the plan at a higher rate (Codex docs, Speed).
|
|
334
|
+
if (req.speed === "fast")
|
|
335
|
+
out.service_tier = "priority";
|
|
311
336
|
return out;
|
|
312
337
|
}
|
|
313
338
|
/** Rough token estimate for /v1/messages/count_tokens when the model is not Anthropic's. */
|
|
@@ -506,7 +531,10 @@ export class StreamMapper {
|
|
|
506
531
|
if (this.finished)
|
|
507
532
|
return [];
|
|
508
533
|
this.finished = true;
|
|
509
|
-
const
|
|
534
|
+
const contextOverflow = code === "context_length_exceeded" || /input exceeds the context window/i.test(message);
|
|
535
|
+
const type = contextOverflow ? "invalid_request_error" : code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
|
|
536
|
+
if (contextOverflow)
|
|
537
|
+
message = `prompt is too long: ${message}`;
|
|
510
538
|
this.failure = { type, message };
|
|
511
539
|
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
512
540
|
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// Google Antigravity (Cloud Code Assist) accounts: several Google logins can be signed in, and a
|
|
2
|
+
// turn moves to the next one when the account it is on runs out.
|
|
3
|
+
//
|
|
4
|
+
// Stored in <home>/google-accounts.json (0600, cross-process lock, atomic write), the same shape as
|
|
5
|
+
// the ChatGPT pool. We refresh these; a refresh token Google has invalidated marks only that account
|
|
6
|
+
// for a new sign-in. Identity is the account email plus the discovered Cloud Code Assist project id.
|
|
7
|
+
//
|
|
8
|
+
// Nothing here has been measured against a live Google endpoint — there is no subscription in this
|
|
9
|
+
// environment. The wire facts (token endpoint shape, `invalid_grant`, project discovery) are taken
|
|
10
|
+
// from the reference implementation opencodex as a behavioral spec (src/oauth/google-antigravity.ts)
|
|
11
|
+
// and marked (assumption) in docs/ARCHITECTURE.md §4d.
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { redactErrorText } from "../../redact.js";
|
|
16
|
+
import { withFileLock, writeJsonAtomic } from "../../locked-file.js";
|
|
17
|
+
import { GOOGLE_OAUTH } from "./antigravity.js";
|
|
18
|
+
export function googleAccountsPath(home) {
|
|
19
|
+
return path.join(home, "google-accounts.json");
|
|
20
|
+
}
|
|
21
|
+
function validAccount(value) {
|
|
22
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
23
|
+
return false;
|
|
24
|
+
const a = value;
|
|
25
|
+
return typeof a.id === "string" && a.id.length > 0
|
|
26
|
+
&& typeof a.accessToken === "string" && a.accessToken.length > 0
|
|
27
|
+
&& (a.refreshToken === undefined || typeof a.refreshToken === "string")
|
|
28
|
+
&& typeof a.projectId === "string" && a.projectId.length > 0
|
|
29
|
+
&& typeof a.expiresAt === "number" && Number.isFinite(a.expiresAt)
|
|
30
|
+
&& typeof a.label === "string" && a.label.length > 0
|
|
31
|
+
&& typeof a.createdAt === "string" && typeof a.updatedAt === "string"
|
|
32
|
+
&& (a.email === undefined || typeof a.email === "string")
|
|
33
|
+
&& (a.needsReauth === undefined || typeof a.needsReauth === "boolean")
|
|
34
|
+
&& (a.paused === undefined || typeof a.paused === "boolean");
|
|
35
|
+
}
|
|
36
|
+
/** The file's accounts; [] when absent, null when unreadable (so a mutation refuses to overwrite it). */
|
|
37
|
+
function parseAccountsFile(home) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(googleAccountsPath(home), "utf8"));
|
|
40
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.accounts) || !parsed.accounts.every(validAccount))
|
|
41
|
+
return null;
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
return parsed.accounts.filter((account) => !seen.has(account.id) && seen.add(account.id)).map((account) => ({ ...account }));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return error.code === "ENOENT" ? [] : null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function readGoogleAccounts(home) {
|
|
50
|
+
return parseAccountsFile(home) ?? [];
|
|
51
|
+
}
|
|
52
|
+
function readForMutation(home) {
|
|
53
|
+
const accounts = parseAccountsFile(home);
|
|
54
|
+
if (accounts === null)
|
|
55
|
+
throw new Error("Google account store is unreadable; refusing to overwrite it");
|
|
56
|
+
return accounts;
|
|
57
|
+
}
|
|
58
|
+
function write(home, accounts) {
|
|
59
|
+
writeJsonAtomic(googleAccountsPath(home), { version: 1, accounts });
|
|
60
|
+
}
|
|
61
|
+
function mutate(home, change) {
|
|
62
|
+
return withFileLock(googleAccountsPath(home), () => {
|
|
63
|
+
const { accounts, result } = change(readForMutation(home));
|
|
64
|
+
if (accounts)
|
|
65
|
+
write(home, accounts);
|
|
66
|
+
return result;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Same person: the email when both have one, else the same Cloud Code Assist project. */
|
|
70
|
+
function sameIdentity(a, b) {
|
|
71
|
+
if (a.email && b.email)
|
|
72
|
+
return a.email.toLowerCase() === b.email.toLowerCase();
|
|
73
|
+
return a.projectId === b.projectId;
|
|
74
|
+
}
|
|
75
|
+
/** Add a signed-in account, or replace the same one after a re-login. */
|
|
76
|
+
export function saveGoogleAccount(home, grant, now = new Date().toISOString()) {
|
|
77
|
+
return mutate(home, (accounts) => {
|
|
78
|
+
const key = { ...(grant.email ? { email: grant.email } : {}), projectId: grant.projectId };
|
|
79
|
+
const index = accounts.findIndex((account) => sameIdentity(account, key));
|
|
80
|
+
const previous = index >= 0 ? accounts[index] : undefined;
|
|
81
|
+
const account = {
|
|
82
|
+
id: previous?.id ?? crypto.randomUUID(),
|
|
83
|
+
accessToken: grant.accessToken,
|
|
84
|
+
...(grant.refreshToken ? { refreshToken: grant.refreshToken } : {}),
|
|
85
|
+
projectId: grant.projectId,
|
|
86
|
+
...(grant.email ?? previous?.email ? { email: grant.email ?? previous.email } : {}),
|
|
87
|
+
expiresAt: grant.expiresAt,
|
|
88
|
+
label: previous?.label ?? grant.email ?? `Google account ${accounts.length + 1}`,
|
|
89
|
+
createdAt: previous?.createdAt ?? now,
|
|
90
|
+
updatedAt: now,
|
|
91
|
+
...(previous?.paused ? { paused: true } : {}),
|
|
92
|
+
};
|
|
93
|
+
const next = [...accounts];
|
|
94
|
+
if (index >= 0)
|
|
95
|
+
next[index] = account;
|
|
96
|
+
else
|
|
97
|
+
next.push(account);
|
|
98
|
+
return { accounts: next, result: { ...summarize(account), added: index < 0 } };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Compare-and-swap a refreshed grant: an older concurrent refresh must not overwrite a newer one. */
|
|
102
|
+
export function replaceGoogleTokens(home, id, expectedRefreshToken, grant, now = new Date().toISOString()) {
|
|
103
|
+
return mutate(home, (accounts) => {
|
|
104
|
+
const index = accounts.findIndex((account) => account.id === id && account.refreshToken === expectedRefreshToken);
|
|
105
|
+
if (index < 0)
|
|
106
|
+
return { result: false };
|
|
107
|
+
const previous = accounts[index];
|
|
108
|
+
const next = [...accounts];
|
|
109
|
+
next[index] = {
|
|
110
|
+
...previous,
|
|
111
|
+
accessToken: grant.accessToken,
|
|
112
|
+
...(grant.refreshToken ? { refreshToken: grant.refreshToken } : {}),
|
|
113
|
+
projectId: grant.projectId || previous.projectId,
|
|
114
|
+
...(grant.email ? { email: grant.email } : {}),
|
|
115
|
+
expiresAt: grant.expiresAt,
|
|
116
|
+
updatedAt: now,
|
|
117
|
+
needsReauth: false,
|
|
118
|
+
};
|
|
119
|
+
return { accounts: next, result: true };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Mark only the token generation that failed; a sign-in that landed meanwhile wins. */
|
|
123
|
+
export function markGoogleNeedsReauth(home, id, expectedAccessToken) {
|
|
124
|
+
return mutate(home, (accounts) => {
|
|
125
|
+
const index = accounts.findIndex((account) => account.id === id && account.accessToken === expectedAccessToken);
|
|
126
|
+
if (index < 0)
|
|
127
|
+
return { result: false };
|
|
128
|
+
const next = [...accounts];
|
|
129
|
+
next[index] = { ...next[index], needsReauth: true, updatedAt: new Date().toISOString() };
|
|
130
|
+
return { accounts: next, result: true };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export function updateGoogleAccount(home, id, change) {
|
|
134
|
+
const label = change.label?.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 80);
|
|
135
|
+
if (change.label !== undefined && !label)
|
|
136
|
+
return false;
|
|
137
|
+
return mutate(home, (accounts) => {
|
|
138
|
+
const index = accounts.findIndex((account) => account.id === id);
|
|
139
|
+
if (index < 0)
|
|
140
|
+
return { result: false };
|
|
141
|
+
const next = [...accounts];
|
|
142
|
+
const { paused: _paused, ...rest } = next[index];
|
|
143
|
+
next[index] = { ...rest, ...(label ? { label } : {}), ...((change.paused ?? next[index].paused) ? { paused: true } : {}), updatedAt: new Date().toISOString() };
|
|
144
|
+
return { accounts: next, result: true };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export function removeGoogleAccount(home, id) {
|
|
148
|
+
return mutate(home, (accounts) => {
|
|
149
|
+
const next = accounts.filter((account) => account.id !== id);
|
|
150
|
+
return next.length === accounts.length ? { result: false } : { accounts: next, result: true };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export function removeAllGoogleAccounts(home) {
|
|
154
|
+
return withFileLock(googleAccountsPath(home), () => {
|
|
155
|
+
const count = readGoogleAccounts(home).length;
|
|
156
|
+
fs.rmSync(googleAccountsPath(home), { force: true });
|
|
157
|
+
return count;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export function summarize(account) {
|
|
161
|
+
return {
|
|
162
|
+
id: account.id,
|
|
163
|
+
label: account.label,
|
|
164
|
+
...(account.email ? { email: account.email } : {}),
|
|
165
|
+
projectId: account.projectId,
|
|
166
|
+
expiresAt: account.expiresAt,
|
|
167
|
+
needsReauth: account.needsReauth === true,
|
|
168
|
+
paused: account.paused === true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------------------------
|
|
172
|
+
// Runtime
|
|
173
|
+
/** Refresh this long before expiry. A turn never waits for it while the old token still works. */
|
|
174
|
+
const REFRESH_LEAD_MS = GOOGLE_OAUTH.refreshLeadMs;
|
|
175
|
+
export class GoogleRefreshError extends Error {
|
|
176
|
+
terminal;
|
|
177
|
+
constructor(message, terminal) {
|
|
178
|
+
super(message);
|
|
179
|
+
this.terminal = terminal;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Whether a refused refresh means "sign in again". Only Google's `invalid_grant` counts; a 5xx whose
|
|
184
|
+
* body happens to mention "expired" must not retire a working account. Prose is read only when the
|
|
185
|
+
* body carries no `error` code at all and only for a 400/401.
|
|
186
|
+
*/
|
|
187
|
+
export function refreshRejectionIsTerminal(status, body) {
|
|
188
|
+
let code;
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(body);
|
|
191
|
+
if (typeof parsed.error === "string")
|
|
192
|
+
code = parsed.error;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* not JSON */
|
|
196
|
+
}
|
|
197
|
+
if (code)
|
|
198
|
+
return code === "invalid_grant";
|
|
199
|
+
return (status === 400 || status === 401) && /\binvalid_grant\b/i.test(body);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Exchange a refresh token for a new grant. Google's token endpoint takes a form-encoded body with
|
|
203
|
+
* the client id and secret (this is a public installed-app client). Throws GoogleRefreshError.
|
|
204
|
+
* Source: opencodex `postToken` / `refreshAntigravityToken`.
|
|
205
|
+
*/
|
|
206
|
+
export async function refreshGoogleGrant(refreshToken, previous, fetchImpl = fetch, now = Date.now) {
|
|
207
|
+
let res;
|
|
208
|
+
try {
|
|
209
|
+
res = await fetchImpl(GOOGLE_OAUTH.tokenUrl, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
212
|
+
body: new URLSearchParams({ grant_type: "refresh_token", client_id: GOOGLE_OAUTH.clientId, client_secret: GOOGLE_OAUTH.clientSecret, refresh_token: refreshToken }).toString(),
|
|
213
|
+
signal: AbortSignal.timeout(GOOGLE_OAUTH.requestTimeoutMs),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
throw new GoogleRefreshError(`token refresh unreachable: ${error.message}`, false);
|
|
218
|
+
}
|
|
219
|
+
const text = await res.text().catch(() => "");
|
|
220
|
+
if (!res.ok)
|
|
221
|
+
throw new GoogleRefreshError(`token refresh failed: HTTP ${res.status} ${redactErrorText(text, [refreshToken], 200)}`, refreshRejectionIsTerminal(res.status, text));
|
|
222
|
+
let j;
|
|
223
|
+
try {
|
|
224
|
+
j = JSON.parse(text);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
throw new GoogleRefreshError("token refresh answered with something other than JSON", false);
|
|
228
|
+
}
|
|
229
|
+
if (!j.access_token)
|
|
230
|
+
throw new GoogleRefreshError("token refresh answered without an access token", false);
|
|
231
|
+
const expiresIn = typeof j.expires_in === "number" && Number.isFinite(j.expires_in) && j.expires_in > 0 ? j.expires_in : 3600;
|
|
232
|
+
// The project was settled at sign-in (an account cannot be stored without one), so a refresh does
|
|
233
|
+
// not ask again: discovery can poll `onboardUser` for seconds, once an hour, for an answer it has.
|
|
234
|
+
return {
|
|
235
|
+
accessToken: j.access_token,
|
|
236
|
+
refreshToken: j.refresh_token ?? refreshToken,
|
|
237
|
+
projectId: previous.projectId,
|
|
238
|
+
...(previous.email ? { email: previous.email } : {}),
|
|
239
|
+
expiresAt: now() + expiresIn * 1000,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** One refresh per token generation across every adapter in this process. */
|
|
243
|
+
const refreshing = new Map();
|
|
244
|
+
function credentialOf(ownerId, label, accessToken, projectId) {
|
|
245
|
+
// The runtime id is the account itself, not its token: a usage limit belongs to the account, and a
|
|
246
|
+
// refresh must not bring a spent account back early.
|
|
247
|
+
return { id: ownerId, ownerId, label, accessToken, projectId, headers: { authorization: `Bearer ${accessToken}` } };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The Antigravity accounts one google provider may use. Refreshes what is due without holding a turn
|
|
251
|
+
* up, and can force a refresh when the backend refuses a token that has not expired yet.
|
|
252
|
+
*/
|
|
253
|
+
export class GoogleAccountPool {
|
|
254
|
+
home;
|
|
255
|
+
log;
|
|
256
|
+
fetchImpl;
|
|
257
|
+
now;
|
|
258
|
+
constructor(options) {
|
|
259
|
+
this.home = options.home;
|
|
260
|
+
this.log = options.log;
|
|
261
|
+
this.fetchImpl = options.fetch;
|
|
262
|
+
this.now = options.now ?? Date.now;
|
|
263
|
+
}
|
|
264
|
+
ownAccounts() {
|
|
265
|
+
return readGoogleAccounts(this.home);
|
|
266
|
+
}
|
|
267
|
+
/** Accounts that could be sent right now, without network I/O. */
|
|
268
|
+
peekCredentials() {
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const account of this.ownAccounts()) {
|
|
271
|
+
if (account.paused || account.needsReauth || this.now() >= account.expiresAt)
|
|
272
|
+
continue;
|
|
273
|
+
out.push(credentialOf(account.id, account.label, account.accessToken, account.projectId));
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/** Accounts for one turn. Waits for a refresh only when nothing could be sent otherwise. */
|
|
278
|
+
async credentials() {
|
|
279
|
+
const ready = this.peekCredentials();
|
|
280
|
+
const due = this.refreshDue();
|
|
281
|
+
if (ready.length > 0) {
|
|
282
|
+
void due.catch((error) => this.log.warn(`google account refresh task failed: ${error.message}`));
|
|
283
|
+
return ready;
|
|
284
|
+
}
|
|
285
|
+
await due;
|
|
286
|
+
return this.peekCredentials();
|
|
287
|
+
}
|
|
288
|
+
/** Refresh every stored account inside its lead window. */
|
|
289
|
+
async refreshDue() {
|
|
290
|
+
const due = this.ownAccounts().filter((account) => !account.needsReauth && account.refreshToken && this.now() >= account.expiresAt - REFRESH_LEAD_MS);
|
|
291
|
+
await Promise.all(due.map((account) => this.refresh(account)));
|
|
292
|
+
}
|
|
293
|
+
/** The project id for an owner, from the store. */
|
|
294
|
+
projectFor(ownerId) {
|
|
295
|
+
return this.ownAccounts().find((account) => account.id === ownerId)?.projectId;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The backend refused this account's token before it expired. One refresh; true when a new token
|
|
299
|
+
* is in place.
|
|
300
|
+
*/
|
|
301
|
+
async forceRefresh(ownerId) {
|
|
302
|
+
const account = this.ownAccounts().find((candidate) => candidate.id === ownerId);
|
|
303
|
+
if (!account?.refreshToken || account.needsReauth)
|
|
304
|
+
return false;
|
|
305
|
+
const before = account.accessToken;
|
|
306
|
+
await this.refresh(account);
|
|
307
|
+
const after = this.ownAccounts().find((candidate) => candidate.id === ownerId);
|
|
308
|
+
return !!after && !after.needsReauth && after.accessToken !== before;
|
|
309
|
+
}
|
|
310
|
+
/** A token the backend refused after a refresh: that account needs a new sign-in. */
|
|
311
|
+
reject(credential) {
|
|
312
|
+
if (markGoogleNeedsReauth(this.home, credential.ownerId, credential.accessToken)) {
|
|
313
|
+
this.log.warn(`google account ${credential.ownerId.slice(0, 8)}: token refused; sign-in required`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
refresh(account) {
|
|
317
|
+
const refreshToken = account.refreshToken;
|
|
318
|
+
const key = `${this.home}\0${account.id}\0${crypto.createHash("sha256").update(refreshToken).digest("hex")}`;
|
|
319
|
+
const running = refreshing.get(key);
|
|
320
|
+
if (running)
|
|
321
|
+
return running;
|
|
322
|
+
const task = refreshGoogleGrant(refreshToken, account, this.fetchImpl, this.now).then((grant) => {
|
|
323
|
+
if (replaceGoogleTokens(this.home, account.id, refreshToken, grant)) {
|
|
324
|
+
this.log.info(`google account ${account.id.slice(0, 8)}: token refreshed, valid until ${new Date(grant.expiresAt).toISOString()}`);
|
|
325
|
+
}
|
|
326
|
+
}, (error) => {
|
|
327
|
+
if (error instanceof GoogleRefreshError && error.terminal) {
|
|
328
|
+
markGoogleNeedsReauth(this.home, account.id, account.accessToken);
|
|
329
|
+
this.log.warn(`google account ${account.id.slice(0, 8)}: refresh rejected; sign-in required`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
this.log.warn(`google account ${account.id.slice(0, 8)}: refresh failed: ${redactErrorText(error.message, [account.accessToken, refreshToken], 300)}`);
|
|
333
|
+
}
|
|
334
|
+
}).finally(() => refreshing.delete(key));
|
|
335
|
+
refreshing.set(key, task);
|
|
336
|
+
return task;
|
|
337
|
+
}
|
|
338
|
+
summaries() {
|
|
339
|
+
return this.ownAccounts().map(summarize);
|
|
340
|
+
}
|
|
341
|
+
/** Whether anything is signed in at all, expired or not — the "log in first" check. */
|
|
342
|
+
signedIn() {
|
|
343
|
+
return this.ownAccounts().length > 0;
|
|
344
|
+
}
|
|
345
|
+
}
|