clawmoney 0.13.15 → 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 +35 -13
- 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,
|
|
@@ -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 ?? "?"})`);
|
|
@@ -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"
|