clawmoney 0.15.47 → 0.15.48

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.
@@ -8,6 +8,7 @@ import { apiPost } from "../utils/api.js";
8
8
  import { loadConfig, requireConfig } from "../utils/config.js";
9
9
  import { setupCommand } from "./setup.js";
10
10
  import { API_PRICES, PLATFORM_FEE } from "../relay/pricing.js";
11
+ import { hasClaudeFingerprint, bootstrapClaudeFingerprint, } from "../relay/upstream/claude-bootstrap.js";
11
12
  // ── Per-cli_type model catalogs ──
12
13
  //
13
14
  // `RECOMMENDED_MODELS` is what gets registered when the user picks "all
@@ -203,6 +204,37 @@ export async function relaySetupCommand() {
203
204
  process.exit(0);
204
205
  }
205
206
  const selectedClis = familyChoice;
207
+ // ── Step 2b: bootstrap per-cli fingerprints if missing ──
208
+ //
209
+ // Claude / Codex / Gemini daemons each need a fingerprint file
210
+ // (~/.clawmoney/<cli>-fingerprint.json) to mimic the real CLI's
211
+ // device_id + account_uuid when relaying requests. Without it,
212
+ // every relay request fails at execution time and the buyer sees
213
+ // 502s.
214
+ //
215
+ // Previously users had to run a two-terminal capture dance
216
+ // manually. Now we do it inline: start a local proxy, invoke
217
+ // `claude -p hi` (etc) against it, intercept the first
218
+ // /v1/messages request, extract the fingerprint, forward the
219
+ // request upstream so the CLI call still succeeds.
220
+ //
221
+ // Only claude is wired up for now; codex/gemini will follow the
222
+ // same pattern.
223
+ if (selectedClis.includes("claude") && !hasClaudeFingerprint()) {
224
+ const bootSpin = spinner();
225
+ bootSpin.start("Capturing Claude fingerprint (runs `claude -p hi` once, ~5-15s)...");
226
+ try {
227
+ const fp = await bootstrapClaudeFingerprint({ timeoutMs: 45_000 });
228
+ bootSpin.stop(`${chalk.green("✓")} Claude fingerprint captured ` +
229
+ chalk.dim(`(device=${fp.device_id.slice(0, 8)}… cc_version=${fp.cc_version || "?"})`));
230
+ }
231
+ catch (err) {
232
+ bootSpin.stop(chalk.yellow(`⚠ Claude fingerprint capture failed: ${err.message}`));
233
+ log.warn("Claude providers will be registered but the daemon won't be able " +
234
+ "to serve them until you run `clawmoney relay setup` again or bootstrap " +
235
+ "manually. Make sure `claude` is installed and logged in first.");
236
+ }
237
+ }
206
238
  const registrations = [];
207
239
  for (const cli of selectedClis) {
208
240
  const allModels = modelsForCli(cli);
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Programmatic Claude fingerprint capture.
3
+ *
4
+ * Mirrors scripts/capture-claude-request.mjs but runs as a library so the
5
+ * setup wizard can bootstrap the fingerprint automatically instead of
6
+ * asking the user to run a two-terminal dance.
7
+ *
8
+ * Flow:
9
+ * 1. Listen on a random localhost port.
10
+ * 2. Spawn `claude -p "hi"` with ANTHROPIC_BASE_URL pointing at us.
11
+ * 3. When the first POST /v1/messages arrives, extract device_id,
12
+ * account_uuid, user_agent, cc_version, cc_entrypoint from the
13
+ * body + headers, persist to ~/.clawmoney/claude-fingerprint.json,
14
+ * and forward the request to api.anthropic.com so the claude CLI
15
+ * still sees a real response.
16
+ * 4. Clean up proxy server + claude subprocess.
17
+ *
18
+ * The forwarded request costs 1-2 cents' worth of tokens on the
19
+ * provider's real Claude Max subscription — acceptable for a one-time
20
+ * bootstrap that otherwise blocks every subsequent relay request.
21
+ */
22
+ export interface ClaudeFingerprint {
23
+ device_id: string;
24
+ account_uuid: string;
25
+ user_agent: string;
26
+ cc_version: string;
27
+ cc_entrypoint: string;
28
+ }
29
+ export declare function hasClaudeFingerprint(): boolean;
30
+ export declare function bootstrapClaudeFingerprint(opts?: {
31
+ timeoutMs?: number;
32
+ }): Promise<ClaudeFingerprint>;
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Programmatic Claude fingerprint capture.
3
+ *
4
+ * Mirrors scripts/capture-claude-request.mjs but runs as a library so the
5
+ * setup wizard can bootstrap the fingerprint automatically instead of
6
+ * asking the user to run a two-terminal dance.
7
+ *
8
+ * Flow:
9
+ * 1. Listen on a random localhost port.
10
+ * 2. Spawn `claude -p "hi"` with ANTHROPIC_BASE_URL pointing at us.
11
+ * 3. When the first POST /v1/messages arrives, extract device_id,
12
+ * account_uuid, user_agent, cc_version, cc_entrypoint from the
13
+ * body + headers, persist to ~/.clawmoney/claude-fingerprint.json,
14
+ * and forward the request to api.anthropic.com so the claude CLI
15
+ * still sees a real response.
16
+ * 4. Clean up proxy server + claude subprocess.
17
+ *
18
+ * The forwarded request costs 1-2 cents' worth of tokens on the
19
+ * provider's real Claude Max subscription — acceptable for a one-time
20
+ * bootstrap that otherwise blocks every subsequent relay request.
21
+ */
22
+ import { createServer } from "node:http";
23
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync, } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { spawn } from "node:child_process";
27
+ import { fetch as undiciFetch, ProxyAgent } from "undici";
28
+ const CONFIG_DIR = join(homedir(), ".clawmoney");
29
+ const FINGERPRINT_PATH = join(CONFIG_DIR, "claude-fingerprint.json");
30
+ export function hasClaudeFingerprint() {
31
+ return existsSync(FINGERPRINT_PATH);
32
+ }
33
+ const HOP_BY_HOP = new Set([
34
+ "host",
35
+ "connection",
36
+ "content-length",
37
+ "transfer-encoding",
38
+ "accept-encoding",
39
+ ]);
40
+ function cloneHeaders(src) {
41
+ const out = {};
42
+ for (const [k, v] of Object.entries(src)) {
43
+ if (v == null)
44
+ continue;
45
+ if (HOP_BY_HOP.has(k.toLowerCase()))
46
+ continue;
47
+ out[k] = Array.isArray(v) ? v.join(", ") : v;
48
+ }
49
+ return out;
50
+ }
51
+ function extractFingerprint(body, headers) {
52
+ if (!body || typeof body !== "object")
53
+ return null;
54
+ const metadata = body.metadata;
55
+ const userIdRaw = metadata?.user_id;
56
+ if (typeof userIdRaw !== "string")
57
+ return null;
58
+ let userId;
59
+ try {
60
+ userId = JSON.parse(userIdRaw);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ if (!userId.device_id || !userId.account_uuid)
66
+ return null;
67
+ const uaRaw = headers["user-agent"];
68
+ const ua = Array.isArray(uaRaw) ? uaRaw.join(", ") : (uaRaw ?? "");
69
+ // cc_version / cc_entrypoint are embedded in the system prompt as:
70
+ // "x-anthropic-billing-header: cc_version=X; cc_entrypoint=Y; ..."
71
+ let cc_version = "";
72
+ let cc_entrypoint = "";
73
+ const systemArr = body.system;
74
+ if (Array.isArray(systemArr)) {
75
+ for (const entry of systemArr) {
76
+ const text = typeof entry === "string"
77
+ ? entry
78
+ : entry?.text;
79
+ if (typeof text !== "string")
80
+ continue;
81
+ if (text.includes("cc_version=")) {
82
+ const v = text.match(/cc_version=([^;\s]+)/);
83
+ const e = text.match(/cc_entrypoint=([^;\s]+)/);
84
+ if (v)
85
+ cc_version = v[1];
86
+ if (e)
87
+ cc_entrypoint = e[1];
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ return {
93
+ device_id: userId.device_id,
94
+ account_uuid: userId.account_uuid,
95
+ user_agent: ua,
96
+ cc_version,
97
+ cc_entrypoint,
98
+ };
99
+ }
100
+ // Scrub any stray `capture-<ts>.json` files from earlier manual capture
101
+ // runs — they can contain OAuth bearer tokens, so we delete them as soon
102
+ // as the fingerprint write succeeds.
103
+ function scrubCaptureFiles() {
104
+ try {
105
+ for (const f of readdirSync(CONFIG_DIR)) {
106
+ if (/^capture-\d+\.json$/.test(f)) {
107
+ unlinkSync(join(CONFIG_DIR, f));
108
+ }
109
+ }
110
+ }
111
+ catch {
112
+ // ignore — best-effort cleanup
113
+ }
114
+ }
115
+ export async function bootstrapClaudeFingerprint(opts = {}) {
116
+ const timeoutMs = opts.timeoutMs ?? 30_000;
117
+ mkdirSync(CONFIG_DIR, { recursive: true });
118
+ if (hasClaudeFingerprint()) {
119
+ // Caller should check hasClaudeFingerprint() first, but be defensive.
120
+ throw new Error("claude-fingerprint.json already exists — delete it to re-bootstrap");
121
+ }
122
+ const proxyUrl = process.env.HTTPS_PROXY ||
123
+ process.env.https_proxy ||
124
+ process.env.HTTP_PROXY ||
125
+ process.env.http_proxy;
126
+ let upstreamDispatcher;
127
+ if (proxyUrl && /^https?:\/\//.test(proxyUrl)) {
128
+ upstreamDispatcher = new ProxyAgent(proxyUrl);
129
+ }
130
+ let server = null;
131
+ let claudeChild = null;
132
+ let resolved = false;
133
+ let capturedFp = null;
134
+ const cleanup = () => {
135
+ if (claudeChild && !claudeChild.killed) {
136
+ try {
137
+ claudeChild.kill("SIGTERM");
138
+ }
139
+ catch {
140
+ // ignore
141
+ }
142
+ }
143
+ if (server) {
144
+ try {
145
+ server.close();
146
+ }
147
+ catch {
148
+ // ignore
149
+ }
150
+ server = null;
151
+ }
152
+ };
153
+ return new Promise((resolve, reject) => {
154
+ const timer = setTimeout(() => {
155
+ if (resolved)
156
+ return;
157
+ resolved = true;
158
+ cleanup();
159
+ reject(new Error(`claude fingerprint capture timed out after ${timeoutMs}ms (claude -p hi did not complete)`));
160
+ }, timeoutMs);
161
+ server = createServer((req, res) => {
162
+ const chunks = [];
163
+ req.on("data", (c) => chunks.push(c));
164
+ req.on("end", async () => {
165
+ const bodyBuf = Buffer.concat(chunks);
166
+ const bodyText = bodyBuf.toString("utf-8");
167
+ let parsedBody;
168
+ try {
169
+ parsedBody = JSON.parse(bodyText);
170
+ }
171
+ catch {
172
+ parsedBody = bodyText;
173
+ }
174
+ // Try fingerprint extraction on every POST /v1/messages that
175
+ // comes through. We persist the first one that parses cleanly.
176
+ if (!capturedFp &&
177
+ req.method === "POST" &&
178
+ typeof req.url === "string" &&
179
+ req.url.startsWith("/v1/messages")) {
180
+ const fp = extractFingerprint(parsedBody, req.headers);
181
+ if (fp) {
182
+ capturedFp = fp;
183
+ try {
184
+ writeFileSync(FINGERPRINT_PATH, JSON.stringify(fp, null, 2), "utf-8");
185
+ scrubCaptureFiles();
186
+ }
187
+ catch (writeErr) {
188
+ if (!resolved) {
189
+ resolved = true;
190
+ clearTimeout(timer);
191
+ cleanup();
192
+ reject(new Error(`failed to write fingerprint: ${writeErr.message}`));
193
+ return;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ // Forward to upstream so the claude CLI gets a real response
199
+ // and doesn't error out mid-request.
200
+ try {
201
+ const upstreamHeaders = cloneHeaders(req.headers);
202
+ const upstreamResp = await undiciFetch(`https://api.anthropic.com${req.url}`, {
203
+ method: req.method,
204
+ headers: upstreamHeaders,
205
+ body: req.method === "GET" || req.method === "HEAD"
206
+ ? undefined
207
+ : bodyBuf,
208
+ dispatcher: upstreamDispatcher,
209
+ });
210
+ const respHeaders = {};
211
+ upstreamResp.headers.forEach((v, k) => {
212
+ const lower = k.toLowerCase();
213
+ if (lower === "content-encoding" ||
214
+ lower === "content-length" ||
215
+ lower === "transfer-encoding")
216
+ return;
217
+ respHeaders[k] = v;
218
+ });
219
+ res.writeHead(upstreamResp.status, respHeaders);
220
+ if (upstreamResp.body) {
221
+ const reader = upstreamResp.body.getReader();
222
+ while (true) {
223
+ const { done: rDone, value } = await reader.read();
224
+ if (rDone)
225
+ break;
226
+ res.write(Buffer.from(value));
227
+ }
228
+ }
229
+ res.end();
230
+ }
231
+ catch (err) {
232
+ try {
233
+ res.writeHead(502);
234
+ res.end();
235
+ }
236
+ catch {
237
+ // socket already closed
238
+ }
239
+ // Upstream proxy failure on the bootstrap request is fatal —
240
+ // without a 2xx back, claude CLI won't finish and we'd block
241
+ // here until timeout. Surface it now.
242
+ if (!resolved && !capturedFp) {
243
+ resolved = true;
244
+ clearTimeout(timer);
245
+ cleanup();
246
+ reject(new Error(`upstream api.anthropic.com request failed: ${err.message}`));
247
+ return;
248
+ }
249
+ }
250
+ // Resolve once we've both captured AND forwarded the response.
251
+ if (capturedFp && !resolved) {
252
+ resolved = true;
253
+ clearTimeout(timer);
254
+ cleanup();
255
+ resolve(capturedFp);
256
+ }
257
+ });
258
+ });
259
+ server.on("error", (err) => {
260
+ if (resolved)
261
+ return;
262
+ resolved = true;
263
+ clearTimeout(timer);
264
+ cleanup();
265
+ reject(new Error(`bootstrap proxy server error: ${err.message}`));
266
+ });
267
+ server.listen(0, "127.0.0.1", () => {
268
+ const addr = server.address();
269
+ if (!addr || typeof addr === "string") {
270
+ if (resolved)
271
+ return;
272
+ resolved = true;
273
+ clearTimeout(timer);
274
+ cleanup();
275
+ reject(new Error("failed to bind capture proxy"));
276
+ return;
277
+ }
278
+ const port = addr.port;
279
+ // Launch `claude -p "hi"` with env pointing at us. No shell on
280
+ // POSIX — spawn walks PATH itself.
281
+ claudeChild = spawn("claude", ["-p", "hi"], {
282
+ env: {
283
+ ...process.env,
284
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
285
+ },
286
+ stdio: "ignore",
287
+ shell: process.platform === "win32",
288
+ });
289
+ claudeChild.on("error", (err) => {
290
+ if (resolved)
291
+ return;
292
+ resolved = true;
293
+ clearTimeout(timer);
294
+ cleanup();
295
+ reject(new Error(`failed to spawn claude: ${err.message} (is the claude CLI installed and in PATH?)`));
296
+ });
297
+ claudeChild.on("exit", (code) => {
298
+ // Give the HTTP handler a short moment to finish writing the
299
+ // fingerprint after claude's subprocess exits. If we still don't
300
+ // have a fingerprint after that, reject with a useful error.
301
+ setTimeout(() => {
302
+ if (capturedFp || resolved)
303
+ return;
304
+ resolved = true;
305
+ clearTimeout(timer);
306
+ cleanup();
307
+ reject(new Error(`claude -p hi exited with code ${code ?? "unknown"} before sending a /v1/messages request ` +
308
+ `(is your claude CLI logged in? try: claude -p hi)`));
309
+ }, 500);
310
+ });
311
+ });
312
+ });
313
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoney",
3
- "version": "0.15.47",
3
+ "version": "0.15.48",
4
4
  "description": "ClawMoney CLI -- Earn rewards with your AI agent",
5
5
  "type": "module",
6
6
  "bin": {