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.
@@ -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 = getRateGuardSnapshot();
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
- writeFileSync(ACCOUNTS_FILE, JSON.stringify(file, null, 2), "utf-8");
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
- try {
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
- persistPrimaryAccount({
295
- access_token: next.access_token,
296
- refresh_token: next.refresh_token,
297
- expiry_ms: next.expiry_ms,
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
- persistPrimaryAccount({ project_id: projectId });
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
- const DEFAULT_CC_VERSION = "2.1.100.f22";
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: raw.cc_version ?? DEFAULT_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 (15-minute sliding window) ──
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 15-minute masked session id — every request within the window gets
159
- // the same id, sliding forward on each hit. We do the same in-process.
160
- const MASKED_SESSION_TTL_MS = 15 * 60 * 1000;
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 maskedSessionLastUsedMs = 0;
193
+ let maskedSessionExpiresAt = 0;
163
194
  function getMaskedSessionId() {
164
195
  const now = Date.now();
165
- if (maskedSessionId && now - maskedSessionLastUsedMs < MASKED_SESSION_TTL_MS) {
166
- maskedSessionLastUsedMs = now;
196
+ if (maskedSessionId && now < maskedSessionExpiresAt) {
167
197
  return maskedSessionId;
168
198
  }
169
199
  maskedSessionId = randomUUID();
170
- maskedSessionLastUsedMs = now;
171
- logger.info(`[claude-api] new masked session_id ${maskedSessionId.slice(0, 8)}... (previous expired)`);
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
- const path = join(homedir(), ".claude", ".credentials.json");
280
- if (!existsSync(path))
355
+ if (!existsSync(CLAUDE_CREDENTIALS_FILE_PATH))
281
356
  return null;
282
357
  try {
283
- return JSON.parse(readFileSync(path, "utf-8"));
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.warn(`[claude-api] keychain write failed: ${err.message}`);
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: `x-anthropic-billing-header: cc_version=${fingerprint.cc_version}; cc_entrypoint=${fingerprint.cc_entrypoint}; cch=00000;`,
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
- const MASKED_SESSION_TTL_MS = 15 * 60 * 1000;
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.warn(`[codex-api] failed to persist refreshed token: ${err.message}`);
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 (15-minute sliding window) ──
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 maskedSessionLastUsedMs = 0;
260
+ let maskedSessionExpiresAt = 0;
250
261
  function getMaskedSessionId() {
251
262
  const now = Date.now();
252
- if (maskedSessionId && now - maskedSessionLastUsedMs < MASKED_SESSION_TTL_MS) {
253
- maskedSessionLastUsedMs = now;
263
+ if (maskedSessionId && now < maskedSessionExpiresAt) {
254
264
  return maskedSessionId;
255
265
  }
256
266
  maskedSessionId = randomUUID();
257
- maskedSessionLastUsedMs = now;
258
- logger.info(`[codex-api] new masked session_id ${maskedSessionId.slice(0, 8)}... (previous expired)`);
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
- try {
130
- const existing = existsSync(GEMINI_CREDS_FILE)
131
- ? JSON.parse(readFileSync(GEMINI_CREDS_FILE, "utf-8"))
132
- : {};
133
- const merged = {
134
- ...existing,
135
- access_token: creds.access_token,
136
- refresh_token: creds.refresh_token,
137
- expiry_date: creds.expiry_date,
138
- token_type: creds.token_type ?? existing["token_type"] ?? "Bearer",
139
- };
140
- if (creds.id_token)
141
- merged["id_token"] = creds.id_token;
142
- if (creds.scope)
143
- merged["scope"] = creds.scope;
144
- writeFileSync(GEMINI_CREDS_FILE, JSON.stringify(merged, null, 2), "utf-8");
145
- logger.info("[gemini-api] ~/.gemini/oauth_creds.json updated");
146
- }
147
- catch (err) {
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
- writeGeminiOAuth(next);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoney",
3
- "version": "0.13.15",
3
+ "version": "0.14.1",
4
4
  "description": "ClawMoney CLI -- Earn rewards with your AI agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"