clawmoney 0.13.13 → 0.14.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/dist/relay/provider.js +24 -5
- package/dist/relay/upstream/antigravity-api.js +55 -19
- package/dist/relay/upstream/claude-api.js +43 -13
- 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,
|
|
@@ -94,17 +94,31 @@ const GENERATE_PATH = "/v1internal:streamGenerateContent?alt=sse";
|
|
|
94
94
|
* names.
|
|
95
95
|
*/
|
|
96
96
|
const ANTIGRAVITY_MODEL_MAP = {
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
97
|
+
// Verified live against a real Ultra account's
|
|
98
|
+
// `v1internal:fetchAvailableModels` response on 2026-04-11. sub2api
|
|
99
|
+
// migration 049's mapping is stale — Google has retired most `4-5`
|
|
100
|
+
// Claude variants and only `claude-opus-4-6-thinking` / `claude-sonnet-4-6`
|
|
101
|
+
// remain, both of which are thinking variants (the displayName literally
|
|
102
|
+
// says "(Thinking)" even for the plain id).
|
|
103
|
+
//
|
|
104
|
+
// Gemini: both `gemini-3-pro-high/low` and `gemini-3.1-pro-high/low` are
|
|
105
|
+
// available — prefer 3.1 for new traffic since Google's generate path
|
|
106
|
+
// sends "no longer available" plain text for 3-pro-high.
|
|
100
107
|
"antigravity-gemini-3-pro": "gemini-3.1-pro-high",
|
|
101
108
|
"antigravity-gemini-3.1-pro": "gemini-3.1-pro-high",
|
|
109
|
+
"antigravity-gemini-3.1-pro-low": "gemini-3.1-pro-low",
|
|
102
110
|
"antigravity-gemini-3-flash": "gemini-3-flash",
|
|
103
111
|
"antigravity-gemini-2.5-pro": "gemini-2.5-pro",
|
|
104
112
|
"antigravity-gemini-2.5-flash": "gemini-2.5-flash",
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"antigravity-claude-opus-4-6": "claude-opus-4-6",
|
|
113
|
+
// Claude. All supported variants are thinking-mode; "4-5" market IDs fall
|
|
114
|
+
// through to 4-6 because that's what Google currently exposes.
|
|
115
|
+
"antigravity-claude-opus-4-6": "claude-opus-4-6-thinking",
|
|
116
|
+
"antigravity-claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
|
|
117
|
+
"antigravity-claude-opus-4-5-thinking": "claude-opus-4-6-thinking",
|
|
118
|
+
"antigravity-claude-sonnet-4-6": "claude-sonnet-4-6",
|
|
119
|
+
"antigravity-claude-sonnet-4-5": "claude-sonnet-4-6",
|
|
120
|
+
"antigravity-claude-sonnet-4-5-thinking": "claude-sonnet-4-6",
|
|
121
|
+
"antigravity-claude-haiku-4-5": "claude-sonnet-4-6",
|
|
108
122
|
};
|
|
109
123
|
function resolveAntigravityUpstreamModel(model) {
|
|
110
124
|
return ANTIGRAVITY_MODEL_MAP[model] ?? model;
|
|
@@ -211,7 +225,13 @@ export function loadAccounts() {
|
|
|
211
225
|
}
|
|
212
226
|
export function saveAccounts(file) {
|
|
213
227
|
ensureClawmoneyDir();
|
|
214
|
-
|
|
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
|
+
});
|
|
215
235
|
}
|
|
216
236
|
function loadPrimaryAccount() {
|
|
217
237
|
const file = loadAccounts();
|
|
@@ -226,17 +246,18 @@ function loadPrimaryAccount() {
|
|
|
226
246
|
}
|
|
227
247
|
return primary;
|
|
228
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
|
+
*/
|
|
229
255
|
function persistPrimaryAccount(patch) {
|
|
230
256
|
const file = loadAccounts();
|
|
231
257
|
if (file.accounts.length === 0)
|
|
232
258
|
return;
|
|
233
259
|
file.accounts[0] = { ...file.accounts[0], ...patch };
|
|
234
|
-
|
|
235
|
-
saveAccounts(file);
|
|
236
|
-
}
|
|
237
|
-
catch (err) {
|
|
238
|
-
logger.warn(`[antigravity-api] could not persist account update: ${err.message}`);
|
|
239
|
-
}
|
|
260
|
+
saveAccounts(file);
|
|
240
261
|
}
|
|
241
262
|
async function refreshUpstreamToken(refreshToken) {
|
|
242
263
|
const params = new URLSearchParams({
|
|
@@ -277,11 +298,19 @@ async function doRefreshAndPersist(current) {
|
|
|
277
298
|
refresh_token: fresh.refresh_token,
|
|
278
299
|
expiry_ms: fresh.expiry_ms,
|
|
279
300
|
};
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
}
|
|
285
314
|
return next;
|
|
286
315
|
}
|
|
287
316
|
async function getFreshAccount() {
|
|
@@ -496,7 +525,14 @@ export async function preflightAntigravityApi(config) {
|
|
|
496
525
|
logger.info("[antigravity-api] resolving project ID via loadCodeAssist...");
|
|
497
526
|
const projectId = await resolveAntigravityProjectId(account.access_token);
|
|
498
527
|
account.project_id = projectId;
|
|
499
|
-
|
|
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
|
+
}
|
|
500
536
|
cachedAccount = account;
|
|
501
537
|
}
|
|
502
538
|
logger.info(`[antigravity-api] preflight OK (project=${account.project_id}, email=${account.email ?? "?"})`);
|
|
@@ -149,26 +149,37 @@ function buildMetadataUserID(fingerprint, sessionId) {
|
|
|
149
149
|
session_id: sessionId,
|
|
150
150
|
});
|
|
151
151
|
}
|
|
152
|
-
// ── Masked session id (
|
|
152
|
+
// ── Masked session id (3-minute sliding window, jittered) ──
|
|
153
153
|
//
|
|
154
154
|
// Real Claude Code reuses the same session_id across many requests in the
|
|
155
155
|
// same conversation. If we randomize a new UUID per request, from Anthropic's
|
|
156
156
|
// side this account produces dozens of single-request "sessions" per hour,
|
|
157
157
|
// which is a strong bot signal. sub2api (identity_service.go) solves this
|
|
158
|
-
// with a
|
|
159
|
-
//
|
|
160
|
-
|
|
158
|
+
// with a masked session id — every request within the window gets the same
|
|
159
|
+
// id, sliding forward on each hit.
|
|
160
|
+
//
|
|
161
|
+
// We use a **3-minute** window (not 15) because that matches the median real
|
|
162
|
+
// human coding rhythm better: a user types a prompt, reads the answer, types
|
|
163
|
+
// a follow-up, reads, context-switches. 15 minutes of same-session traffic
|
|
164
|
+
// at machine-paced intervals is itself suspicious for a human operator. We
|
|
165
|
+
// also add ±30s jitter so multiple providers don't all roll their sessions
|
|
166
|
+
// in lockstep at :00 / :03 / :06 etc — that kind of coordinated reset is an
|
|
167
|
+
// obvious relay-farm signature.
|
|
168
|
+
const MASKED_SESSION_TTL_MS = 3 * 60 * 1000; // 3 minutes
|
|
169
|
+
const MASKED_SESSION_JITTER_MS = 30 * 1000; // ±30s
|
|
161
170
|
let maskedSessionId = null;
|
|
162
|
-
let
|
|
171
|
+
let maskedSessionExpiresAt = 0;
|
|
163
172
|
function getMaskedSessionId() {
|
|
164
173
|
const now = Date.now();
|
|
165
|
-
if (maskedSessionId && now
|
|
166
|
-
maskedSessionLastUsedMs = now;
|
|
174
|
+
if (maskedSessionId && now < maskedSessionExpiresAt) {
|
|
167
175
|
return maskedSessionId;
|
|
168
176
|
}
|
|
169
177
|
maskedSessionId = randomUUID();
|
|
170
|
-
|
|
171
|
-
|
|
178
|
+
// New window starts now, expires TTL + jitter from here.
|
|
179
|
+
const jitter = Math.floor((Math.random() * 2 - 1) * MASKED_SESSION_JITTER_MS);
|
|
180
|
+
maskedSessionExpiresAt = now + MASKED_SESSION_TTL_MS + jitter;
|
|
181
|
+
logger.info(`[claude-api] new masked session_id ${maskedSessionId.slice(0, 8)}... ` +
|
|
182
|
+
`(window=${Math.round((MASKED_SESSION_TTL_MS + jitter) / 1000)}s)`);
|
|
172
183
|
return maskedSessionId;
|
|
173
184
|
}
|
|
174
185
|
// ── Prompt sanitization ──
|
|
@@ -275,12 +286,12 @@ function readCredentialsFromKeychain() {
|
|
|
275
286
|
return null;
|
|
276
287
|
}
|
|
277
288
|
}
|
|
289
|
+
const CLAUDE_CREDENTIALS_FILE_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
278
290
|
function readCredentialsFromFile() {
|
|
279
|
-
|
|
280
|
-
if (!existsSync(path))
|
|
291
|
+
if (!existsSync(CLAUDE_CREDENTIALS_FILE_PATH))
|
|
281
292
|
return null;
|
|
282
293
|
try {
|
|
283
|
-
return JSON.parse(readFileSync(
|
|
294
|
+
return JSON.parse(readFileSync(CLAUDE_CREDENTIALS_FILE_PATH, "utf-8"));
|
|
284
295
|
}
|
|
285
296
|
catch {
|
|
286
297
|
return null;
|
|
@@ -299,6 +310,7 @@ function loadClaudeOAuth() {
|
|
|
299
310
|
}
|
|
300
311
|
return {
|
|
301
312
|
source: fromKeychain ? "keychain" : "file",
|
|
313
|
+
filePath: fromKeychain ? undefined : CLAUDE_CREDENTIALS_FILE_PATH,
|
|
302
314
|
accessToken: oauth.accessToken,
|
|
303
315
|
refreshToken: oauth.refreshToken,
|
|
304
316
|
expiresAt: oauth.expiresAt,
|
|
@@ -368,13 +380,31 @@ async function doRefreshAndPersist(current) {
|
|
|
368
380
|
? fresh.scopes
|
|
369
381
|
: wrapper.claudeAiOauth.scopes,
|
|
370
382
|
};
|
|
383
|
+
// IMPORTANT: persist BEFORE advancing the in-memory state. If the keychain
|
|
384
|
+
// write silently fails we must NOT start using the new access/refresh token
|
|
385
|
+
// — doing so creates a "two valid tokens in flight" pattern that looks to
|
|
386
|
+
// Anthropic like account hijacking (same account_id, two access_tokens
|
|
387
|
+
// issued within the 3-minute refresh skew window). The correct fallback is
|
|
388
|
+
// to keep serving on the old token until the next refresh cycle retries
|
|
389
|
+
// the persist, so on-disk and in-memory state always agree.
|
|
371
390
|
if (current.source === "keychain") {
|
|
372
391
|
try {
|
|
373
392
|
writeCredentialsToKeychain(wrapper);
|
|
374
393
|
logger.info("[claude-api] keychain updated");
|
|
375
394
|
}
|
|
376
395
|
catch (err) {
|
|
377
|
-
logger.
|
|
396
|
+
logger.error(`[claude-api] CRITICAL: keychain write failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
|
|
397
|
+
return current;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (current.source === "file" && current.filePath) {
|
|
401
|
+
try {
|
|
402
|
+
writeFileSync(current.filePath, JSON.stringify(wrapper, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
403
|
+
logger.info(`[claude-api] ${current.filePath} updated`);
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
logger.error(`[claude-api] CRITICAL: credential file write failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
|
|
407
|
+
return current;
|
|
378
408
|
}
|
|
379
409
|
}
|
|
380
410
|
const next = {
|
|
@@ -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"
|