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
|
+
}
|