clawmoney 0.13.15 → 0.14.1
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/dist/relay/provider.js +24 -5
- package/dist/relay/upstream/antigravity-api.js +35 -13
- package/dist/relay/upstream/claude-api.js +115 -17
- package/dist/relay/upstream/codex-api.js +20 -8
- package/dist/relay/upstream/gemini-api.js +35 -22
- package/package.json +1 -1
- package/scripts/install-daemon-launchd.sh +128 -0
package/dist/relay/provider.js
CHANGED
|
@@ -4,10 +4,29 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
import { RelayWsClient } from "./ws-client.js";
|
|
6
6
|
import { spawnCli, buildCliArgs, parseCliOutput, ensureEmptyMcpConfig, ensureSandboxDir, } from "./executor.js";
|
|
7
|
-
import { callClaudeApi, preflightClaudeApi, getRateGuardSnapshot } from "./upstream/claude-api.js";
|
|
8
|
-
import { callCodexApi, preflightCodexApi } from "./upstream/codex-api.js";
|
|
9
|
-
import { callGeminiApi, preflightGeminiApi } from "./upstream/gemini-api.js";
|
|
10
|
-
import { callAntigravityApi, preflightAntigravityApi, } from "./upstream/antigravity-api.js";
|
|
7
|
+
import { callClaudeApi, preflightClaudeApi, getRateGuardSnapshot as getClaudeRateGuardSnapshot, } from "./upstream/claude-api.js";
|
|
8
|
+
import { callCodexApi, preflightCodexApi, getRateGuardSnapshot as getCodexRateGuardSnapshot, } from "./upstream/codex-api.js";
|
|
9
|
+
import { callGeminiApi, preflightGeminiApi, getGeminiRateGuardSnapshot, } from "./upstream/gemini-api.js";
|
|
10
|
+
import { callAntigravityApi, preflightAntigravityApi, getAntigravityRateGuardSnapshot, } from "./upstream/antigravity-api.js";
|
|
11
|
+
/**
|
|
12
|
+
* Pick the rate-guard snapshot matching this request's cli_type. Fixes a
|
|
13
|
+
* pre-existing bug where gemini/codex responses were piggy-backing Claude's
|
|
14
|
+
* session_window telemetry because provider.ts always called the claude-api
|
|
15
|
+
* snapshot regardless of upstream.
|
|
16
|
+
*/
|
|
17
|
+
function getRateGuardSnapshotForCli(cli) {
|
|
18
|
+
switch (cli) {
|
|
19
|
+
case "codex":
|
|
20
|
+
return getCodexRateGuardSnapshot();
|
|
21
|
+
case "gemini":
|
|
22
|
+
return getGeminiRateGuardSnapshot();
|
|
23
|
+
case "antigravity":
|
|
24
|
+
return getAntigravityRateGuardSnapshot();
|
|
25
|
+
case "claude":
|
|
26
|
+
default:
|
|
27
|
+
return getClaudeRateGuardSnapshot();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
11
30
|
import { calculateCost } from "./pricing.js";
|
|
12
31
|
import { relayLogger as logger } from "./logger.js";
|
|
13
32
|
const CONFIG_DIR = join(homedir(), ".clawmoney");
|
|
@@ -222,7 +241,7 @@ async function executeRelayRequest(request, config) {
|
|
|
222
241
|
// actually surfaced the headers this turn.
|
|
223
242
|
let sessionWindowTelemetry;
|
|
224
243
|
if (useApiMode) {
|
|
225
|
-
const snap =
|
|
244
|
+
const snap = getRateGuardSnapshotForCli(cliType);
|
|
226
245
|
if (snap?.sessionWindow) {
|
|
227
246
|
sessionWindowTelemetry = {
|
|
228
247
|
reset_at_ms: snap.sessionWindow.endMs,
|
|
@@ -225,7 +225,13 @@ export function loadAccounts() {
|
|
|
225
225
|
}
|
|
226
226
|
export function saveAccounts(file) {
|
|
227
227
|
ensureClawmoneyDir();
|
|
228
|
-
|
|
228
|
+
// mode 0o600 so other local users / rogue processes can't read the
|
|
229
|
+
// refresh_token. Google's fraud detection treats a refresh_token seen
|
|
230
|
+
// from two machines / user-agents as account hijacking.
|
|
231
|
+
writeFileSync(ACCOUNTS_FILE, JSON.stringify(file, null, 2), {
|
|
232
|
+
encoding: "utf-8",
|
|
233
|
+
mode: 0o600,
|
|
234
|
+
});
|
|
229
235
|
}
|
|
230
236
|
function loadPrimaryAccount() {
|
|
231
237
|
const file = loadAccounts();
|
|
@@ -240,17 +246,18 @@ function loadPrimaryAccount() {
|
|
|
240
246
|
}
|
|
241
247
|
return primary;
|
|
242
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Persist a partial update to the primary account record. Throws on write
|
|
251
|
+
* failure — refresh callers must treat that as a reason to keep the old
|
|
252
|
+
* token (see the "two valid tokens in flight" rationale in claude-api.ts).
|
|
253
|
+
* Non-refresh callers (project_id cache) can swallow the error.
|
|
254
|
+
*/
|
|
243
255
|
function persistPrimaryAccount(patch) {
|
|
244
256
|
const file = loadAccounts();
|
|
245
257
|
if (file.accounts.length === 0)
|
|
246
258
|
return;
|
|
247
259
|
file.accounts[0] = { ...file.accounts[0], ...patch };
|
|
248
|
-
|
|
249
|
-
saveAccounts(file);
|
|
250
|
-
}
|
|
251
|
-
catch (err) {
|
|
252
|
-
logger.warn(`[antigravity-api] could not persist account update: ${err.message}`);
|
|
253
|
-
}
|
|
260
|
+
saveAccounts(file);
|
|
254
261
|
}
|
|
255
262
|
async function refreshUpstreamToken(refreshToken) {
|
|
256
263
|
const params = new URLSearchParams({
|
|
@@ -291,11 +298,19 @@ async function doRefreshAndPersist(current) {
|
|
|
291
298
|
refresh_token: fresh.refresh_token,
|
|
292
299
|
expiry_ms: fresh.expiry_ms,
|
|
293
300
|
};
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
// Persist FIRST. If writing to antigravity-accounts.json fails, keep
|
|
302
|
+
// using the old token — see claude-api.ts doRefreshAndPersist for why.
|
|
303
|
+
try {
|
|
304
|
+
persistPrimaryAccount({
|
|
305
|
+
access_token: next.access_token,
|
|
306
|
+
refresh_token: next.refresh_token,
|
|
307
|
+
expiry_ms: next.expiry_ms,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
logger.error(`[antigravity-api] CRITICAL: persist failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
|
|
312
|
+
return current;
|
|
313
|
+
}
|
|
299
314
|
return next;
|
|
300
315
|
}
|
|
301
316
|
async function getFreshAccount() {
|
|
@@ -510,7 +525,14 @@ export async function preflightAntigravityApi(config) {
|
|
|
510
525
|
logger.info("[antigravity-api] resolving project ID via loadCodeAssist...");
|
|
511
526
|
const projectId = await resolveAntigravityProjectId(account.access_token);
|
|
512
527
|
account.project_id = projectId;
|
|
513
|
-
|
|
528
|
+
// Persist is non-critical here: failure just means we'll re-resolve on
|
|
529
|
+
// next daemon restart instead of hitting the cache.
|
|
530
|
+
try {
|
|
531
|
+
persistPrimaryAccount({ project_id: projectId });
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
logger.warn(`[antigravity-api] failed to cache project_id: ${err.message}`);
|
|
535
|
+
}
|
|
514
536
|
cachedAccount = account;
|
|
515
537
|
}
|
|
516
538
|
logger.info(`[antigravity-api] preflight OK (project=${account.project_id}, email=${account.email ?? "?"})`);
|
|
@@ -21,7 +21,7 @@ import { execFileSync } from "node:child_process";
|
|
|
21
21
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { homedir, userInfo } from "node:os";
|
|
24
|
-
import { randomUUID } from "node:crypto";
|
|
24
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
25
25
|
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
|
26
26
|
import { relayLogger as logger } from "../logger.js";
|
|
27
27
|
import { RateGuard, RateGuardBudgetExceededError, RateGuardCooldownError, } from "./rate-guard.js";
|
|
@@ -39,9 +39,20 @@ const FINGERPRINT_FILE = join(CLAWMONEY_DIR, "claude-fingerprint.json");
|
|
|
39
39
|
// schema). Bootstrapping with the new capture script will replace these
|
|
40
40
|
// with the values observed on the actual Provider machine.
|
|
41
41
|
const DEFAULT_CLI_VERSION = "2.1.100";
|
|
42
|
-
|
|
42
|
+
// NOTE: DEFAULT_CC_VERSION is only used as a fallback if the fingerprint file
|
|
43
|
+
// doesn't tell us the CLI's base version. The 3-char suffix is always
|
|
44
|
+
// recomputed per-request via computeClaudeFingerprint() — storing a baked
|
|
45
|
+
// suffix here would make every request look identical to Anthropic's
|
|
46
|
+
// fingerprint matcher, which is the relay-farm signature we want to avoid.
|
|
47
|
+
const DEFAULT_CC_VERSION = DEFAULT_CLI_VERSION;
|
|
43
48
|
const DEFAULT_CC_ENTRYPOINT = "cli";
|
|
44
49
|
const DEFAULT_USER_AGENT = `claude-cli/${DEFAULT_CLI_VERSION} (external, ${DEFAULT_CC_ENTRYPOINT})`;
|
|
50
|
+
// Hardcoded salt from Claude Code's backend fingerprint validator. Lifted
|
|
51
|
+
// verbatim from `src/utils/fingerprint.ts` in the reconstructed source map
|
|
52
|
+
// (claude-code-sourcemap) and cross-checked against cc-haha's copy of the
|
|
53
|
+
// same file — both projects have the identical string. This value is part
|
|
54
|
+
// of Anthropic's server-side check that the request came from a real CLI.
|
|
55
|
+
const CLAUDE_FINGERPRINT_SALT = "59cf53e54c78";
|
|
45
56
|
const STATIC_CLAUDE_CODE_HEADERS = {
|
|
46
57
|
"accept": "application/json",
|
|
47
58
|
"x-stainless-retry-count": "0",
|
|
@@ -126,11 +137,22 @@ function loadFingerprint() {
|
|
|
126
137
|
}
|
|
127
138
|
// Older fingerprint files only have device_id + account_uuid. Fill in
|
|
128
139
|
// sensible defaults for the new fields so we stay backward-compatible.
|
|
140
|
+
//
|
|
141
|
+
// cc_version sanitization: older capture scripts recorded the full
|
|
142
|
+
// "<CLI-version>.<3char-hash>" string Anthropic sent back (e.g.
|
|
143
|
+
// "2.1.100.c68"). That trailing hash is a per-request fingerprint of
|
|
144
|
+
// the prompt content — baking it into every outbound request means all
|
|
145
|
+
// of this provider's traffic shares the same fingerprint suffix even
|
|
146
|
+
// though prompts differ, which is a strong relay-farm signal. Strip it
|
|
147
|
+
// here so the at-rest cc_version is the bare CLI version, and let
|
|
148
|
+
// computeClaudeFingerprint() recompute the suffix per request.
|
|
149
|
+
const rawCcVersion = raw.cc_version ?? DEFAULT_CC_VERSION;
|
|
150
|
+
const cleanCcVersion = rawCcVersion.replace(/\.[a-f0-9]{3}$/i, "");
|
|
129
151
|
cachedFingerprint = {
|
|
130
152
|
device_id: raw.device_id,
|
|
131
153
|
account_uuid: raw.account_uuid,
|
|
132
154
|
user_agent: raw.user_agent ?? DEFAULT_USER_AGENT,
|
|
133
|
-
cc_version:
|
|
155
|
+
cc_version: cleanCcVersion,
|
|
134
156
|
cc_entrypoint: raw.cc_entrypoint ?? DEFAULT_CC_ENTRYPOINT,
|
|
135
157
|
};
|
|
136
158
|
if (raw.user_agent || raw.cc_version || raw.cc_entrypoint) {
|
|
@@ -149,26 +171,37 @@ function buildMetadataUserID(fingerprint, sessionId) {
|
|
|
149
171
|
session_id: sessionId,
|
|
150
172
|
});
|
|
151
173
|
}
|
|
152
|
-
// ── Masked session id (
|
|
174
|
+
// ── Masked session id (3-minute sliding window, jittered) ──
|
|
153
175
|
//
|
|
154
176
|
// Real Claude Code reuses the same session_id across many requests in the
|
|
155
177
|
// same conversation. If we randomize a new UUID per request, from Anthropic's
|
|
156
178
|
// side this account produces dozens of single-request "sessions" per hour,
|
|
157
179
|
// which is a strong bot signal. sub2api (identity_service.go) solves this
|
|
158
|
-
// with a
|
|
159
|
-
//
|
|
160
|
-
|
|
180
|
+
// with a masked session id — every request within the window gets the same
|
|
181
|
+
// id, sliding forward on each hit.
|
|
182
|
+
//
|
|
183
|
+
// We use a **3-minute** window (not 15) because that matches the median real
|
|
184
|
+
// human coding rhythm better: a user types a prompt, reads the answer, types
|
|
185
|
+
// a follow-up, reads, context-switches. 15 minutes of same-session traffic
|
|
186
|
+
// at machine-paced intervals is itself suspicious for a human operator. We
|
|
187
|
+
// also add ±30s jitter so multiple providers don't all roll their sessions
|
|
188
|
+
// in lockstep at :00 / :03 / :06 etc — that kind of coordinated reset is an
|
|
189
|
+
// obvious relay-farm signature.
|
|
190
|
+
const MASKED_SESSION_TTL_MS = 3 * 60 * 1000; // 3 minutes
|
|
191
|
+
const MASKED_SESSION_JITTER_MS = 30 * 1000; // ±30s
|
|
161
192
|
let maskedSessionId = null;
|
|
162
|
-
let
|
|
193
|
+
let maskedSessionExpiresAt = 0;
|
|
163
194
|
function getMaskedSessionId() {
|
|
164
195
|
const now = Date.now();
|
|
165
|
-
if (maskedSessionId && now
|
|
166
|
-
maskedSessionLastUsedMs = now;
|
|
196
|
+
if (maskedSessionId && now < maskedSessionExpiresAt) {
|
|
167
197
|
return maskedSessionId;
|
|
168
198
|
}
|
|
169
199
|
maskedSessionId = randomUUID();
|
|
170
|
-
|
|
171
|
-
|
|
200
|
+
// New window starts now, expires TTL + jitter from here.
|
|
201
|
+
const jitter = Math.floor((Math.random() * 2 - 1) * MASKED_SESSION_JITTER_MS);
|
|
202
|
+
maskedSessionExpiresAt = now + MASKED_SESSION_TTL_MS + jitter;
|
|
203
|
+
logger.info(`[claude-api] new masked session_id ${maskedSessionId.slice(0, 8)}... ` +
|
|
204
|
+
`(window=${Math.round((MASKED_SESSION_TTL_MS + jitter) / 1000)}s)`);
|
|
172
205
|
return maskedSessionId;
|
|
173
206
|
}
|
|
174
207
|
// ── Prompt sanitization ──
|
|
@@ -185,6 +218,48 @@ const IDENTITY_REPLACEMENTS = [
|
|
|
185
218
|
"You are Claude Code, Anthropic's official CLI for Claude.",
|
|
186
219
|
],
|
|
187
220
|
];
|
|
221
|
+
// ── Attribution fingerprint ──
|
|
222
|
+
//
|
|
223
|
+
// Claude Code's server-side fingerprint validator expects the outgoing
|
|
224
|
+
// /v1/messages request to contain, as the first system block, a text node
|
|
225
|
+
// of the form:
|
|
226
|
+
//
|
|
227
|
+
// x-anthropic-billing-header: cc_version=<CLI-VERSION>.<FP3>; cc_entrypoint=<EP>;
|
|
228
|
+
//
|
|
229
|
+
// where <FP3> is a per-request 3-hex-char hash that Anthropic derives from
|
|
230
|
+
// the first user message's content and the CLI version. The algorithm is
|
|
231
|
+
// verbatim from the reconstructed Claude Code source
|
|
232
|
+
// (claude-code-sourcemap/restored-src/src/utils/fingerprint.ts, cross-
|
|
233
|
+
// verified against cc-haha/src/utils/fingerprint.ts):
|
|
234
|
+
//
|
|
235
|
+
// chars = msg[4] + msg[7] + msg[20] (each char, "0" if OOB)
|
|
236
|
+
// input = SALT + chars + version
|
|
237
|
+
// hash = sha256(input).hex
|
|
238
|
+
// fp = hash[:3]
|
|
239
|
+
//
|
|
240
|
+
// If every request we send reuses the SAME baked <FP3> (e.g. the one that
|
|
241
|
+
// happened to be recorded when capture-claude-request.mjs ran), Anthropic
|
|
242
|
+
// can observe: same account_uuid, wildly different first-user-message
|
|
243
|
+
// texts, but identical cc_version suffix — a strong relay-farm signal.
|
|
244
|
+
// Computing it per request removes that signal.
|
|
245
|
+
function computeClaudeFingerprint(firstUserMessageText, cliVersion) {
|
|
246
|
+
const indices = [4, 7, 20];
|
|
247
|
+
const chars = indices.map((i) => firstUserMessageText[i] ?? "0").join("");
|
|
248
|
+
const input = `${CLAUDE_FINGERPRINT_SALT}${chars}${cliVersion}`;
|
|
249
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 3);
|
|
250
|
+
}
|
|
251
|
+
function buildClaudeAttributionHeader(firstUserMessageText, cliVersion, entrypoint) {
|
|
252
|
+
const fp = computeClaudeFingerprint(firstUserMessageText, cliVersion);
|
|
253
|
+
// NOTE: real Claude Code optionally appends ` cch=00000;` when its Bun
|
|
254
|
+
// native client has NATIVE_CLIENT_ATTESTATION enabled — the Bun HTTP
|
|
255
|
+
// stack then rewrites the zeros with an attestation token in-flight.
|
|
256
|
+
// We can't replicate that (no Bun runtime, no native attester), and the
|
|
257
|
+
// server also accepts the header without it (feature() guarded in
|
|
258
|
+
// sourcemap's getAttributionHeader), so we omit cch entirely rather
|
|
259
|
+
// than sending a literal `cch=00000;` that would fail attestation on
|
|
260
|
+
// tiers where Anthropic validates it.
|
|
261
|
+
return `x-anthropic-billing-header: cc_version=${cliVersion}.${fp}; cc_entrypoint=${entrypoint};`;
|
|
262
|
+
}
|
|
188
263
|
function sanitizePrompt(prompt) {
|
|
189
264
|
if (!prompt)
|
|
190
265
|
return prompt;
|
|
@@ -275,12 +350,12 @@ function readCredentialsFromKeychain() {
|
|
|
275
350
|
return null;
|
|
276
351
|
}
|
|
277
352
|
}
|
|
353
|
+
const CLAUDE_CREDENTIALS_FILE_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
278
354
|
function readCredentialsFromFile() {
|
|
279
|
-
|
|
280
|
-
if (!existsSync(path))
|
|
355
|
+
if (!existsSync(CLAUDE_CREDENTIALS_FILE_PATH))
|
|
281
356
|
return null;
|
|
282
357
|
try {
|
|
283
|
-
return JSON.parse(readFileSync(
|
|
358
|
+
return JSON.parse(readFileSync(CLAUDE_CREDENTIALS_FILE_PATH, "utf-8"));
|
|
284
359
|
}
|
|
285
360
|
catch {
|
|
286
361
|
return null;
|
|
@@ -299,6 +374,7 @@ function loadClaudeOAuth() {
|
|
|
299
374
|
}
|
|
300
375
|
return {
|
|
301
376
|
source: fromKeychain ? "keychain" : "file",
|
|
377
|
+
filePath: fromKeychain ? undefined : CLAUDE_CREDENTIALS_FILE_PATH,
|
|
302
378
|
accessToken: oauth.accessToken,
|
|
303
379
|
refreshToken: oauth.refreshToken,
|
|
304
380
|
expiresAt: oauth.expiresAt,
|
|
@@ -368,13 +444,31 @@ async function doRefreshAndPersist(current) {
|
|
|
368
444
|
? fresh.scopes
|
|
369
445
|
: wrapper.claudeAiOauth.scopes,
|
|
370
446
|
};
|
|
447
|
+
// IMPORTANT: persist BEFORE advancing the in-memory state. If the keychain
|
|
448
|
+
// write silently fails we must NOT start using the new access/refresh token
|
|
449
|
+
// — doing so creates a "two valid tokens in flight" pattern that looks to
|
|
450
|
+
// Anthropic like account hijacking (same account_id, two access_tokens
|
|
451
|
+
// issued within the 3-minute refresh skew window). The correct fallback is
|
|
452
|
+
// to keep serving on the old token until the next refresh cycle retries
|
|
453
|
+
// the persist, so on-disk and in-memory state always agree.
|
|
371
454
|
if (current.source === "keychain") {
|
|
372
455
|
try {
|
|
373
456
|
writeCredentialsToKeychain(wrapper);
|
|
374
457
|
logger.info("[claude-api] keychain updated");
|
|
375
458
|
}
|
|
376
459
|
catch (err) {
|
|
377
|
-
logger.
|
|
460
|
+
logger.error(`[claude-api] CRITICAL: keychain write failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
|
|
461
|
+
return current;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
else if (current.source === "file" && current.filePath) {
|
|
465
|
+
try {
|
|
466
|
+
writeFileSync(current.filePath, JSON.stringify(wrapper, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
467
|
+
logger.info(`[claude-api] ${current.filePath} updated`);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
logger.error(`[claude-api] CRITICAL: credential file write failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
|
|
471
|
+
return current;
|
|
378
472
|
}
|
|
379
473
|
}
|
|
380
474
|
const next = {
|
|
@@ -542,13 +636,17 @@ async function doCallClaudeApi(opts) {
|
|
|
542
636
|
// one-shot sessions.
|
|
543
637
|
const sessionId = getMaskedSessionId();
|
|
544
638
|
const maxTokens = opts.maxTokens ?? 4096;
|
|
639
|
+
// Dynamic attribution header — computed per request from the first user
|
|
640
|
+
// message text so the cc_version.<FP3> suffix varies request-by-request,
|
|
641
|
+
// matching what real Claude Code sends. See computeClaudeFingerprint().
|
|
642
|
+
const attributionHeader = buildClaudeAttributionHeader(sanitizedPrompt, fingerprint.cc_version, fingerprint.cc_entrypoint);
|
|
545
643
|
const body = {
|
|
546
644
|
model: normalizeModel(opts.model),
|
|
547
645
|
max_tokens: maxTokens,
|
|
548
646
|
system: [
|
|
549
647
|
{
|
|
550
648
|
type: "text",
|
|
551
|
-
text:
|
|
649
|
+
text: attributionHeader,
|
|
552
650
|
},
|
|
553
651
|
{
|
|
554
652
|
type: "text",
|
|
@@ -62,7 +62,10 @@ const DEFAULT_USER_AGENT = "";
|
|
|
62
62
|
// openai-beta header value for the 0.118+ WebSocket protocol.
|
|
63
63
|
const OPENAI_BETA_WS_VALUE = "responses_websockets=2026-02-06";
|
|
64
64
|
const REFRESH_SKEW_MS = 3 * 60 * 1000;
|
|
65
|
-
|
|
65
|
+
// Matches claude-api.ts MASKED_SESSION_TTL_MS — 3 minutes with ±30s jitter
|
|
66
|
+
// to mimic human coding rhythm and avoid all providers rolling in lockstep.
|
|
67
|
+
const MASKED_SESSION_TTL_MS = 3 * 60 * 1000;
|
|
68
|
+
const MASKED_SESSION_JITTER_MS = 30 * 1000;
|
|
66
69
|
const MAX_TRANSIENT_RETRIES = 2;
|
|
67
70
|
// Per-call upper bound on how long we wait for a terminal WS frame.
|
|
68
71
|
// Codex responses on small prompts come back in <10s; we give a generous
|
|
@@ -213,12 +216,17 @@ async function doRefreshAndPersist(current) {
|
|
|
213
216
|
refresh_token: fresh.refreshToken,
|
|
214
217
|
},
|
|
215
218
|
};
|
|
219
|
+
// Persist FIRST, then advance in-memory state. If the on-disk write fails
|
|
220
|
+
// we keep serving on the old token — see claude-api.ts doRefreshAndPersist
|
|
221
|
+
// for the full rationale (OpenAI/ChatGPT would see two valid access tokens
|
|
222
|
+
// in flight for the same account and mark it as hijacked otherwise).
|
|
216
223
|
try {
|
|
217
224
|
writeCodexAuth(updatedFile);
|
|
218
225
|
logger.info("[codex-api] ~/.codex/auth.json updated");
|
|
219
226
|
}
|
|
220
227
|
catch (err) {
|
|
221
|
-
logger.
|
|
228
|
+
logger.error(`[codex-api] CRITICAL: persist failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
|
|
229
|
+
return current;
|
|
222
230
|
}
|
|
223
231
|
return {
|
|
224
232
|
accessToken: fresh.accessToken,
|
|
@@ -244,18 +252,22 @@ async function getFreshCreds() {
|
|
|
244
252
|
cachedCreds = await refreshInflight;
|
|
245
253
|
return cachedCreds;
|
|
246
254
|
}
|
|
247
|
-
// ── Masked session id (
|
|
255
|
+
// ── Masked session id (3-minute sliding window, jittered) ──
|
|
256
|
+
// See the claude-api.ts copy of this block for the full rationale — real
|
|
257
|
+
// Codex reuses the same id across consecutive requests in a conversation;
|
|
258
|
+
// rolling one per request screams bot. Kept in sync with claude's TTL.
|
|
248
259
|
let maskedSessionId = null;
|
|
249
|
-
let
|
|
260
|
+
let maskedSessionExpiresAt = 0;
|
|
250
261
|
function getMaskedSessionId() {
|
|
251
262
|
const now = Date.now();
|
|
252
|
-
if (maskedSessionId && now
|
|
253
|
-
maskedSessionLastUsedMs = now;
|
|
263
|
+
if (maskedSessionId && now < maskedSessionExpiresAt) {
|
|
254
264
|
return maskedSessionId;
|
|
255
265
|
}
|
|
256
266
|
maskedSessionId = randomUUID();
|
|
257
|
-
|
|
258
|
-
|
|
267
|
+
const jitter = Math.floor((Math.random() * 2 - 1) * MASKED_SESSION_JITTER_MS);
|
|
268
|
+
maskedSessionExpiresAt = now + MASKED_SESSION_TTL_MS + jitter;
|
|
269
|
+
logger.info(`[codex-api] new masked session_id ${maskedSessionId.slice(0, 8)}... ` +
|
|
270
|
+
`(window=${Math.round((MASKED_SESSION_TTL_MS + jitter) / 1000)}s)`);
|
|
259
271
|
return maskedSessionId;
|
|
260
272
|
}
|
|
261
273
|
// ── Rate-limit cooldown parsing ──
|
|
@@ -125,28 +125,33 @@ function loadGeminiOAuth() {
|
|
|
125
125
|
}
|
|
126
126
|
return raw;
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Persist refreshed credentials to ~/.gemini/oauth_creds.json. Throws on
|
|
130
|
+
* failure — the caller (doRefreshAndPersist) must treat a failed write as
|
|
131
|
+
* a reason to keep the OLD token rather than advancing in-memory state, to
|
|
132
|
+
* avoid the "two valid access tokens for the same account" signal that
|
|
133
|
+
* Google's fraud detection interprets as account hijacking.
|
|
134
|
+
*/
|
|
128
135
|
function writeGeminiOAuth(creds) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
logger.warn(`[gemini-api] could not persist refreshed token: ${err.message}`);
|
|
149
|
-
}
|
|
136
|
+
const existing = existsSync(GEMINI_CREDS_FILE)
|
|
137
|
+
? JSON.parse(readFileSync(GEMINI_CREDS_FILE, "utf-8"))
|
|
138
|
+
: {};
|
|
139
|
+
const merged = {
|
|
140
|
+
...existing,
|
|
141
|
+
access_token: creds.access_token,
|
|
142
|
+
refresh_token: creds.refresh_token,
|
|
143
|
+
expiry_date: creds.expiry_date,
|
|
144
|
+
token_type: creds.token_type ?? existing["token_type"] ?? "Bearer",
|
|
145
|
+
};
|
|
146
|
+
if (creds.id_token)
|
|
147
|
+
merged["id_token"] = creds.id_token;
|
|
148
|
+
if (creds.scope)
|
|
149
|
+
merged["scope"] = creds.scope;
|
|
150
|
+
writeFileSync(GEMINI_CREDS_FILE, JSON.stringify(merged, null, 2), {
|
|
151
|
+
encoding: "utf-8",
|
|
152
|
+
mode: 0o600,
|
|
153
|
+
});
|
|
154
|
+
logger.info("[gemini-api] ~/.gemini/oauth_creds.json updated");
|
|
150
155
|
}
|
|
151
156
|
async function refreshUpstreamToken(refreshToken) {
|
|
152
157
|
// Google OAuth2 uses application/x-www-form-urlencoded (not JSON like Claude's).
|
|
@@ -193,7 +198,15 @@ async function doRefreshAndPersist(current) {
|
|
|
193
198
|
scope: fresh.scope ?? current.scope,
|
|
194
199
|
token_type: fresh.token_type,
|
|
195
200
|
};
|
|
196
|
-
|
|
201
|
+
// Persist FIRST. If writing to ~/.gemini/oauth_creds.json fails, keep the
|
|
202
|
+
// old token — see claude-api.ts doRefreshAndPersist for the rationale.
|
|
203
|
+
try {
|
|
204
|
+
writeGeminiOAuth(next);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
logger.error(`[gemini-api] CRITICAL: persist failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
|
|
208
|
+
return current;
|
|
209
|
+
}
|
|
197
210
|
return next;
|
|
198
211
|
}
|
|
199
212
|
async function getFreshCreds() {
|
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install the ClawMoney relay daemon as a launchd user agent on macOS.
|
|
3
|
+
#
|
|
4
|
+
# Why: macOS Keychain is locked in non-interactive SSH sessions, so running
|
|
5
|
+
# `clawmoney relay start` via `ssh host clawmoney relay start` silently fails
|
|
6
|
+
# to read Claude Code's OAuth token. A launchd *user* agent (LaunchAgents)
|
|
7
|
+
# runs in the user's GUI session context, where the Keychain is unlocked as
|
|
8
|
+
# long as the user is logged in at the console.
|
|
9
|
+
#
|
|
10
|
+
# This script generates ~/Library/LaunchAgents/ai.clawmoney.relay.plist, wires
|
|
11
|
+
# it up to the absolute path of the clawmoney binary, and loads it. After
|
|
12
|
+
# running this once, the daemon will auto-start on login and auto-restart if
|
|
13
|
+
# it crashes — no more "why did my daemon die overnight" pages.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# ./scripts/install-daemon-launchd.sh # install + start
|
|
17
|
+
# ./scripts/install-daemon-launchd.sh uninstall # unload + remove plist
|
|
18
|
+
#
|
|
19
|
+
# Pre-reqs:
|
|
20
|
+
# - `clawmoney setup` has already written ~/.clawmoney/config.yaml
|
|
21
|
+
# - `clawmoney antigravity login` (if using antigravity)
|
|
22
|
+
# - You've installed clawmoney globally (npm i -g clawmoney)
|
|
23
|
+
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
LABEL="ai.clawmoney.relay"
|
|
27
|
+
PLIST_PATH="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
28
|
+
LOG_DIR="$HOME/.clawmoney"
|
|
29
|
+
STDOUT_LOG="$LOG_DIR/launchd.out.log"
|
|
30
|
+
STDERR_LOG="$LOG_DIR/launchd.err.log"
|
|
31
|
+
|
|
32
|
+
if [[ "$(uname)" != "Darwin" ]]; then
|
|
33
|
+
echo "error: this script is for macOS only (launchd)" >&2
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
if [[ "${1:-}" == "uninstall" ]]; then
|
|
38
|
+
echo "Unloading $LABEL..."
|
|
39
|
+
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
|
40
|
+
rm -f "$PLIST_PATH"
|
|
41
|
+
echo "Removed $PLIST_PATH."
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
CLAWMONEY_BIN="$(command -v clawmoney || true)"
|
|
46
|
+
if [[ -z "$CLAWMONEY_BIN" ]]; then
|
|
47
|
+
echo "error: clawmoney not found in PATH. Install with: npm i -g clawmoney" >&2
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# launchd executes with a minimal PATH, so we resolve the real node too and
|
|
52
|
+
# pass it through EnvironmentVariables. The clawmoney binary itself is a
|
|
53
|
+
# Node shebang, so we need node on PATH at run time.
|
|
54
|
+
NODE_BIN="$(command -v node || true)"
|
|
55
|
+
if [[ -z "$NODE_BIN" ]]; then
|
|
56
|
+
echo "error: node not found in PATH" >&2
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
NODE_DIR="$(dirname "$NODE_BIN")"
|
|
60
|
+
|
|
61
|
+
mkdir -p "$LOG_DIR"
|
|
62
|
+
mkdir -p "$(dirname "$PLIST_PATH")"
|
|
63
|
+
|
|
64
|
+
cat > "$PLIST_PATH" <<EOF
|
|
65
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
66
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
67
|
+
<plist version="1.0">
|
|
68
|
+
<dict>
|
|
69
|
+
<key>Label</key>
|
|
70
|
+
<string>$LABEL</string>
|
|
71
|
+
|
|
72
|
+
<key>ProgramArguments</key>
|
|
73
|
+
<array>
|
|
74
|
+
<string>$CLAWMONEY_BIN</string>
|
|
75
|
+
<string>relay</string>
|
|
76
|
+
<string>start</string>
|
|
77
|
+
</array>
|
|
78
|
+
|
|
79
|
+
<key>RunAtLoad</key>
|
|
80
|
+
<true/>
|
|
81
|
+
|
|
82
|
+
<key>KeepAlive</key>
|
|
83
|
+
<true/>
|
|
84
|
+
|
|
85
|
+
<!-- Restart no more than once per 10 seconds if it crashes in a loop. -->
|
|
86
|
+
<key>ThrottleInterval</key>
|
|
87
|
+
<integer>10</integer>
|
|
88
|
+
|
|
89
|
+
<!-- Run inside the user's GUI login session so the Keychain is unlocked
|
|
90
|
+
and we can read Claude Code's OAuth token via `security`. -->
|
|
91
|
+
<key>ProcessType</key>
|
|
92
|
+
<string>Interactive</string>
|
|
93
|
+
|
|
94
|
+
<key>EnvironmentVariables</key>
|
|
95
|
+
<dict>
|
|
96
|
+
<!-- launchd's default PATH doesn't include Homebrew / nvm. Point at the
|
|
97
|
+
real node dir so the clawmoney shebang can find its interpreter. -->
|
|
98
|
+
<key>PATH</key>
|
|
99
|
+
<string>$NODE_DIR:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
100
|
+
<key>HOME</key>
|
|
101
|
+
<string>$HOME</string>
|
|
102
|
+
</dict>
|
|
103
|
+
|
|
104
|
+
<key>WorkingDirectory</key>
|
|
105
|
+
<string>$HOME</string>
|
|
106
|
+
|
|
107
|
+
<key>StandardOutPath</key>
|
|
108
|
+
<string>$STDOUT_LOG</string>
|
|
109
|
+
<key>StandardErrorPath</key>
|
|
110
|
+
<string>$STDERR_LOG</string>
|
|
111
|
+
</dict>
|
|
112
|
+
</plist>
|
|
113
|
+
EOF
|
|
114
|
+
|
|
115
|
+
chmod 0644 "$PLIST_PATH"
|
|
116
|
+
|
|
117
|
+
echo "Wrote $PLIST_PATH."
|
|
118
|
+
echo "Loading into launchd..."
|
|
119
|
+
|
|
120
|
+
# bootout first in case we're reinstalling.
|
|
121
|
+
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
|
122
|
+
launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH"
|
|
123
|
+
|
|
124
|
+
echo ""
|
|
125
|
+
echo "Daemon is running under launchd."
|
|
126
|
+
echo " logs: $STDOUT_LOG / $STDERR_LOG"
|
|
127
|
+
echo " check: clawmoney relay status"
|
|
128
|
+
echo " stop: ./scripts/install-daemon-launchd.sh uninstall"
|