clawmoney 0.15.53 → 0.15.55
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.
|
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import * as readline from "node:readline";
|
|
5
6
|
import { intro, outro, multiselect, select, spinner, isCancel, cancel, log, } from "@clack/prompts";
|
|
6
7
|
import chalk from "chalk";
|
|
7
8
|
import { apiPost } from "../utils/api.js";
|
|
@@ -9,6 +10,8 @@ import { loadConfig, requireConfig } from "../utils/config.js";
|
|
|
9
10
|
import { setupCommand } from "./setup.js";
|
|
10
11
|
import { API_PRICES, PLATFORM_FEE } from "../relay/pricing.js";
|
|
11
12
|
import { hasClaudeFingerprint, bootstrapClaudeFingerprint, } from "../relay/upstream/claude-bootstrap.js";
|
|
13
|
+
import { hasGeminiFingerprint, bootstrapGeminiFingerprint, } from "../relay/upstream/gemini-bootstrap.js";
|
|
14
|
+
import { hasCodexFingerprint, bootstrapCodexFingerprint, } from "../relay/upstream/codex-bootstrap.js";
|
|
12
15
|
// ── Per-cli_type model catalogs ──
|
|
13
16
|
//
|
|
14
17
|
// `RECOMMENDED_MODELS` is what gets registered when the user picks "all
|
|
@@ -204,48 +207,95 @@ export async function relaySetupCommand() {
|
|
|
204
207
|
process.exit(0);
|
|
205
208
|
}
|
|
206
209
|
const selectedClis = familyChoice;
|
|
207
|
-
//
|
|
210
|
+
// Per-cli inline fingerprint bootstrap helper. Called from Step 3
|
|
211
|
+
// right after the "cli: N models" line so the capture flow is
|
|
212
|
+
// visually grouped with its owning cli family.
|
|
208
213
|
//
|
|
209
|
-
//
|
|
214
|
+
// The daemon needs a fingerprint file per cli_type
|
|
210
215
|
// (~/.clawmoney/<cli>-fingerprint.json) to mimic the real CLI's
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
216
|
+
// device identity on every upstream request. Without it, the
|
|
217
|
+
// daemon fails at execution time and buyers see 502s. We
|
|
218
|
+
// previously required a manual two-terminal capture dance; now
|
|
219
|
+
// it's inlined into the wizard.
|
|
214
220
|
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// /v1/messages request, extract the fingerprint, forward the
|
|
219
|
-
// request upstream so the CLI call still succeeds.
|
|
221
|
+
// claude uses a plain HTTP proxy (in-process TS), same for gemini.
|
|
222
|
+
// codex uses the existing mjs capture script via subprocess
|
|
223
|
+
// because Codex CLI 0.118+ talks WebSocket.
|
|
220
224
|
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
225
|
+
// antigravity is handled separately via `clawmoney antigravity
|
|
226
|
+
// login` and doesn't need a fingerprint file.
|
|
227
|
+
const runCliBootstrap = async (cli) => {
|
|
228
|
+
let startLabel;
|
|
229
|
+
let run;
|
|
230
|
+
let check;
|
|
231
|
+
if (cli === "claude") {
|
|
232
|
+
if (hasClaudeFingerprint())
|
|
233
|
+
return;
|
|
234
|
+
startLabel = "Capturing Claude fingerprint (runs `claude -p hi` once, ~5-15s)";
|
|
235
|
+
check = hasClaudeFingerprint;
|
|
236
|
+
run = async () => {
|
|
237
|
+
const fp = await bootstrapClaudeFingerprint({ timeoutMs: 45_000 });
|
|
238
|
+
return `device=${fp.device_id.slice(0, 8)}… cc_version=${fp.cc_version || "?"}`;
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
else if (cli === "gemini") {
|
|
242
|
+
if (hasGeminiFingerprint())
|
|
243
|
+
return;
|
|
244
|
+
startLabel = "Capturing Gemini fingerprint (runs `gemini -p hi` once, ~10-20s)";
|
|
245
|
+
check = hasGeminiFingerprint;
|
|
246
|
+
run = async () => {
|
|
247
|
+
const fp = await bootstrapGeminiFingerprint({ timeoutMs: 60_000 });
|
|
248
|
+
return `project=${fp.project_id} cli_version=${fp.cli_version}`;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
else if (cli === "codex") {
|
|
252
|
+
if (hasCodexFingerprint())
|
|
253
|
+
return;
|
|
254
|
+
startLabel = "Capturing Codex fingerprint (runs `codex -p hi` once, ~15-30s)";
|
|
255
|
+
check = hasCodexFingerprint;
|
|
256
|
+
run = async () => {
|
|
257
|
+
await bootstrapCodexFingerprint({ timeoutMs: 60_000 });
|
|
258
|
+
return "from chatgpt.com WS handshake";
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// antigravity or unknown — skip, no fingerprint needed.
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// In-place line replacement: print start line without a newline,
|
|
266
|
+
// then clear it on completion and print the result over the top.
|
|
267
|
+
// Uses readline.cursorTo/clearLine for terminal portability.
|
|
268
|
+
const startLine = `${chalk.gray("◇")} ${chalk.bold(startLabel)}`;
|
|
269
|
+
process.stdout.write(startLine);
|
|
270
|
+
const clearStartLine = () => {
|
|
271
|
+
try {
|
|
272
|
+
readline.cursorTo(process.stdout, 0);
|
|
273
|
+
readline.clearLine(process.stdout, 0);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
process.stdout.write("\n");
|
|
277
|
+
}
|
|
278
|
+
};
|
|
233
279
|
try {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
process.stdout.write("
|
|
237
|
-
|
|
238
|
-
|
|
280
|
+
const summary = await run();
|
|
281
|
+
clearStartLine();
|
|
282
|
+
process.stdout.write(`${chalk.green("◆")} ${chalk.bold(cli)} fingerprint captured ` +
|
|
283
|
+
chalk.dim(`(${summary})`) +
|
|
284
|
+
"\n");
|
|
239
285
|
}
|
|
240
286
|
catch (err) {
|
|
241
|
-
|
|
242
|
-
process.stdout.write("\n
|
|
243
|
-
log.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
"Make sure `claude` is installed and logged in, then re-run setup."));
|
|
287
|
+
clearStartLine();
|
|
288
|
+
process.stdout.write(`${chalk.yellow("⚠")} ${chalk.bold(cli)} fingerprint capture failed: ${err.message}\n`);
|
|
289
|
+
log.message(chalk.dim(`${cli} providers will be registered but the daemon won't be able ` +
|
|
290
|
+
"to serve them until the fingerprint is bootstrapped. " +
|
|
291
|
+
`Make sure \`${cli}\` is installed and logged in, then re-run setup.`));
|
|
247
292
|
}
|
|
248
|
-
|
|
293
|
+
// Defensive: if the bootstrap somehow resolved without writing
|
|
294
|
+
// the file, warn so the user isn't surprised later.
|
|
295
|
+
if (!check()) {
|
|
296
|
+
log.message(chalk.dim(`(${cli} fingerprint file still missing — daemon preflight will fail for this cli)`));
|
|
297
|
+
}
|
|
298
|
+
};
|
|
249
299
|
const registrations = [];
|
|
250
300
|
for (const cli of selectedClis) {
|
|
251
301
|
const allModels = modelsForCli(cli);
|
|
@@ -255,6 +305,10 @@ export async function relaySetupCommand() {
|
|
|
255
305
|
continue;
|
|
256
306
|
}
|
|
257
307
|
log.success(`${chalk.bold(cli)}: ${recommended.length} models ${chalk.dim("— " + recommended.join(", "))}`);
|
|
308
|
+
// Bootstrap the fingerprint for this cli right after its model
|
|
309
|
+
// line so the ◆ "fingerprint captured" message sits visually
|
|
310
|
+
// under its owning family (claude / codex / gemini / antigravity).
|
|
311
|
+
await runCliBootstrap(cli);
|
|
258
312
|
for (const model of recommended) {
|
|
259
313
|
const p = API_PRICES[model];
|
|
260
314
|
registrations.push({
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic Codex fingerprint capture.
|
|
3
|
+
*
|
|
4
|
+
* Codex CLI 0.118+ talks to chatgpt.com over a WebSocket Upgrade
|
|
5
|
+
* (GET /v1/responses Upgrade: websocket) rather than plain HTTPS,
|
|
6
|
+
* which makes the in-process TS proxy approach used for Claude and
|
|
7
|
+
* Gemini much harder — we'd need to port the full handshake + frame
|
|
8
|
+
* decoder. So instead we reuse the existing
|
|
9
|
+
* `scripts/capture-codex-request.mjs` script which already handles
|
|
10
|
+
* all of that correctly: spawn it as a subprocess, run `codex -p hi`
|
|
11
|
+
* against it, and wait for `~/.clawmoney/codex-fingerprint.json` to
|
|
12
|
+
* appear. On success we SIGINT the capture proxy so it can scrub the
|
|
13
|
+
* transient capture files the way the manual flow does.
|
|
14
|
+
*
|
|
15
|
+
* Note: the mjs script hardcodes port 8788. If something else on the
|
|
16
|
+
* machine is already using that port, the spawn fails and we surface
|
|
17
|
+
* the error.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hasCodexFingerprint(): boolean;
|
|
20
|
+
export declare function bootstrapCodexFingerprint(opts?: {
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic Codex fingerprint capture.
|
|
3
|
+
*
|
|
4
|
+
* Codex CLI 0.118+ talks to chatgpt.com over a WebSocket Upgrade
|
|
5
|
+
* (GET /v1/responses Upgrade: websocket) rather than plain HTTPS,
|
|
6
|
+
* which makes the in-process TS proxy approach used for Claude and
|
|
7
|
+
* Gemini much harder — we'd need to port the full handshake + frame
|
|
8
|
+
* decoder. So instead we reuse the existing
|
|
9
|
+
* `scripts/capture-codex-request.mjs` script which already handles
|
|
10
|
+
* all of that correctly: spawn it as a subprocess, run `codex -p hi`
|
|
11
|
+
* against it, and wait for `~/.clawmoney/codex-fingerprint.json` to
|
|
12
|
+
* appear. On success we SIGINT the capture proxy so it can scrub the
|
|
13
|
+
* transient capture files the way the manual flow does.
|
|
14
|
+
*
|
|
15
|
+
* Note: the mjs script hardcodes port 8788. If something else on the
|
|
16
|
+
* machine is already using that port, the spawn fails and we surface
|
|
17
|
+
* the error.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
const CONFIG_DIR = join(homedir(), ".clawmoney");
|
|
25
|
+
const FINGERPRINT_PATH = join(CONFIG_DIR, "codex-fingerprint.json");
|
|
26
|
+
const CAPTURE_PORT = 8788;
|
|
27
|
+
const CAPTURE_SCRIPT = "capture-codex-request.mjs";
|
|
28
|
+
export function hasCodexFingerprint() {
|
|
29
|
+
return existsSync(FINGERPRINT_PATH);
|
|
30
|
+
}
|
|
31
|
+
// Locate the mjs capture script relative to the installed dist.
|
|
32
|
+
// After TS compilation this file ends up at
|
|
33
|
+
// <pkg>/dist/relay/upstream/codex-bootstrap.js
|
|
34
|
+
// and the scripts live at <pkg>/scripts/capture-codex-request.mjs
|
|
35
|
+
// so the relative walk is ../../../scripts.
|
|
36
|
+
function findCaptureScript() {
|
|
37
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
38
|
+
const thisDir = dirname(thisFile);
|
|
39
|
+
const candidates = [
|
|
40
|
+
join(thisDir, "..", "..", "..", "scripts", CAPTURE_SCRIPT),
|
|
41
|
+
join(thisDir, "..", "..", "scripts", CAPTURE_SCRIPT),
|
|
42
|
+
join(thisDir, "..", "scripts", CAPTURE_SCRIPT),
|
|
43
|
+
];
|
|
44
|
+
for (const c of candidates) {
|
|
45
|
+
if (existsSync(c))
|
|
46
|
+
return c;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
export async function bootstrapCodexFingerprint(opts = {}) {
|
|
51
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
52
|
+
if (hasCodexFingerprint()) {
|
|
53
|
+
throw new Error("codex-fingerprint.json already exists — delete it to re-bootstrap");
|
|
54
|
+
}
|
|
55
|
+
const scriptPath = findCaptureScript();
|
|
56
|
+
if (!scriptPath) {
|
|
57
|
+
throw new Error(`capture-codex-request.mjs not found in the installed clawmoney package`);
|
|
58
|
+
}
|
|
59
|
+
let proxyChild = null;
|
|
60
|
+
let codexChild = null;
|
|
61
|
+
let pollInterval = null;
|
|
62
|
+
let done = false;
|
|
63
|
+
const cleanup = () => {
|
|
64
|
+
if (pollInterval) {
|
|
65
|
+
clearInterval(pollInterval);
|
|
66
|
+
pollInterval = null;
|
|
67
|
+
}
|
|
68
|
+
if (codexChild && !codexChild.killed) {
|
|
69
|
+
try {
|
|
70
|
+
codexChild.kill("SIGTERM");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (proxyChild && !proxyChild.killed) {
|
|
77
|
+
try {
|
|
78
|
+
// SIGINT triggers the mjs script's capture-file scrub
|
|
79
|
+
// cleanup (scrubs OAuth bearer tokens).
|
|
80
|
+
proxyChild.kill("SIGINT");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
if (done)
|
|
90
|
+
return;
|
|
91
|
+
done = true;
|
|
92
|
+
cleanup();
|
|
93
|
+
reject(new Error(`codex fingerprint capture timed out after ${timeoutMs}ms`));
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
// 1. Spawn the capture proxy (mjs script). It inherits the
|
|
96
|
+
// current process's env, including HTTPS_PROXY which it needs
|
|
97
|
+
// to reach chatgpt.com.
|
|
98
|
+
proxyChild = spawn("node", [scriptPath], {
|
|
99
|
+
env: { ...process.env },
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
101
|
+
});
|
|
102
|
+
let proxyStderr = "";
|
|
103
|
+
proxyChild.stderr?.on("data", (c) => {
|
|
104
|
+
proxyStderr += c.toString();
|
|
105
|
+
if (proxyStderr.length > 4_000) {
|
|
106
|
+
proxyStderr = proxyStderr.slice(-4_000);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
proxyChild.stdout?.on("data", () => {
|
|
110
|
+
// drain — the mjs script prints a banner we don't care about
|
|
111
|
+
});
|
|
112
|
+
proxyChild.on("error", (err) => {
|
|
113
|
+
if (done)
|
|
114
|
+
return;
|
|
115
|
+
done = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
cleanup();
|
|
118
|
+
reject(new Error(`failed to spawn capture proxy: ${err.message}`));
|
|
119
|
+
});
|
|
120
|
+
proxyChild.on("exit", (code) => {
|
|
121
|
+
// If the proxy crashed before the fingerprint was captured,
|
|
122
|
+
// reject. If we killed it deliberately after capture, 'done'
|
|
123
|
+
// is already set.
|
|
124
|
+
if (done)
|
|
125
|
+
return;
|
|
126
|
+
if (!hasCodexFingerprint()) {
|
|
127
|
+
done = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
cleanup();
|
|
130
|
+
const tail = proxyStderr.trim().slice(-400);
|
|
131
|
+
const detail = tail ? ` stderr: ${tail}` : "";
|
|
132
|
+
reject(new Error(`capture proxy exited (code ${code ?? "unknown"}) before fingerprint.${detail}`));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
// Wait a moment for the proxy to bind port 8788, then spawn
|
|
136
|
+
// codex. 1.5s is enough on every machine I've tested.
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
if (done)
|
|
139
|
+
return;
|
|
140
|
+
// Poll the fingerprint file — the mjs script writes it as
|
|
141
|
+
// soon as the first upgrade handshake decodes successfully.
|
|
142
|
+
pollInterval = setInterval(() => {
|
|
143
|
+
if (done) {
|
|
144
|
+
if (pollInterval) {
|
|
145
|
+
clearInterval(pollInterval);
|
|
146
|
+
pollInterval = null;
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (hasCodexFingerprint()) {
|
|
151
|
+
done = true;
|
|
152
|
+
if (pollInterval) {
|
|
153
|
+
clearInterval(pollInterval);
|
|
154
|
+
pollInterval = null;
|
|
155
|
+
}
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
cleanup();
|
|
158
|
+
resolve();
|
|
159
|
+
}
|
|
160
|
+
}, 500);
|
|
161
|
+
// Codex wants the proxy-local endpoint; strip HTTPS_PROXY so
|
|
162
|
+
// it talks to 127.0.0.1 directly (going through a proxy to
|
|
163
|
+
// loopback tends to wedge).
|
|
164
|
+
const childEnv = {
|
|
165
|
+
...process.env,
|
|
166
|
+
OPENAI_BASE_URL: `http://127.0.0.1:${CAPTURE_PORT}/v1`,
|
|
167
|
+
NO_PROXY: "127.0.0.1,localhost",
|
|
168
|
+
no_proxy: "127.0.0.1,localhost",
|
|
169
|
+
};
|
|
170
|
+
delete childEnv.HTTPS_PROXY;
|
|
171
|
+
delete childEnv.https_proxy;
|
|
172
|
+
delete childEnv.HTTP_PROXY;
|
|
173
|
+
delete childEnv.http_proxy;
|
|
174
|
+
delete childEnv.ALL_PROXY;
|
|
175
|
+
delete childEnv.all_proxy;
|
|
176
|
+
codexChild = spawn("codex", ["-p", "hi"], {
|
|
177
|
+
env: childEnv,
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
179
|
+
shell: process.platform === "win32",
|
|
180
|
+
});
|
|
181
|
+
let codexStderr = "";
|
|
182
|
+
codexChild.stderr?.on("data", (c) => {
|
|
183
|
+
codexStderr += c.toString();
|
|
184
|
+
if (codexStderr.length > 4_000) {
|
|
185
|
+
codexStderr = codexStderr.slice(-4_000);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
codexChild.stdout?.on("data", () => {
|
|
189
|
+
// drain
|
|
190
|
+
});
|
|
191
|
+
codexChild.on("error", (err) => {
|
|
192
|
+
if (done)
|
|
193
|
+
return;
|
|
194
|
+
done = true;
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
cleanup();
|
|
197
|
+
reject(new Error(`failed to spawn codex: ${err.message} (is the codex CLI installed and in PATH?)`));
|
|
198
|
+
});
|
|
199
|
+
codexChild.on("exit", (code) => {
|
|
200
|
+
// Give the proxy a moment to finish writing the fingerprint
|
|
201
|
+
// after the WS upgrade completes. If no file after that,
|
|
202
|
+
// fail with the stderr tail for diagnostics.
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
if (done || hasCodexFingerprint())
|
|
205
|
+
return;
|
|
206
|
+
done = true;
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
cleanup();
|
|
209
|
+
const tail = codexStderr.trim().slice(-400);
|
|
210
|
+
const detail = tail ? ` stderr: ${tail}` : "";
|
|
211
|
+
reject(new Error(`codex -p hi exited with code ${code ?? "unknown"} before the capture proxy saw a /v1/responses upgrade.${detail}`));
|
|
212
|
+
}, 800);
|
|
213
|
+
});
|
|
214
|
+
}, 1500);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic Gemini fingerprint capture.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors scripts/capture-gemini-request.mjs but runs inline so the
|
|
5
|
+
* setup wizard can bootstrap ~/.clawmoney/gemini-fingerprint.json
|
|
6
|
+
* without the two-terminal dance.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Listen on a random localhost port.
|
|
10
|
+
* 2. Spawn `gemini -p "hi"` with CODE_ASSIST_ENDPOINT pointing at us.
|
|
11
|
+
* 3. When the first POST hits a /v1internal:generateContent (or
|
|
12
|
+
* similar) path, extract project_id / user_agent / cli_version /
|
|
13
|
+
* x_goog_api_client from the body + headers, persist to
|
|
14
|
+
* ~/.clawmoney/gemini-fingerprint.json, and forward the request
|
|
15
|
+
* to cloudcode-pa.googleapis.com so the gemini CLI still sees a
|
|
16
|
+
* valid response.
|
|
17
|
+
* 4. Clean up proxy server + gemini subprocess.
|
|
18
|
+
*
|
|
19
|
+
* Note: the :loadCodeAssist bootstrap request that Gemini CLI fires
|
|
20
|
+
* first carries only `{metadata}` without a project — we skip it and
|
|
21
|
+
* wait for a subsequent v1internal request that actually carries a
|
|
22
|
+
* project field. Mirrors the mjs script's extractFingerprint guard.
|
|
23
|
+
*/
|
|
24
|
+
export interface GeminiFingerprint {
|
|
25
|
+
project_id: string;
|
|
26
|
+
cli_version: string;
|
|
27
|
+
user_agent: string;
|
|
28
|
+
x_goog_api_client: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function hasGeminiFingerprint(): boolean;
|
|
31
|
+
export declare function bootstrapGeminiFingerprint(opts?: {
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}): Promise<GeminiFingerprint>;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic Gemini fingerprint capture.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors scripts/capture-gemini-request.mjs but runs inline so the
|
|
5
|
+
* setup wizard can bootstrap ~/.clawmoney/gemini-fingerprint.json
|
|
6
|
+
* without the two-terminal dance.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Listen on a random localhost port.
|
|
10
|
+
* 2. Spawn `gemini -p "hi"` with CODE_ASSIST_ENDPOINT pointing at us.
|
|
11
|
+
* 3. When the first POST hits a /v1internal:generateContent (or
|
|
12
|
+
* similar) path, extract project_id / user_agent / cli_version /
|
|
13
|
+
* x_goog_api_client from the body + headers, persist to
|
|
14
|
+
* ~/.clawmoney/gemini-fingerprint.json, and forward the request
|
|
15
|
+
* to cloudcode-pa.googleapis.com so the gemini CLI still sees a
|
|
16
|
+
* valid response.
|
|
17
|
+
* 4. Clean up proxy server + gemini subprocess.
|
|
18
|
+
*
|
|
19
|
+
* Note: the :loadCodeAssist bootstrap request that Gemini CLI fires
|
|
20
|
+
* first carries only `{metadata}` without a project — we skip it and
|
|
21
|
+
* wait for a subsequent v1internal request that actually carries a
|
|
22
|
+
* project field. Mirrors the mjs script's extractFingerprint guard.
|
|
23
|
+
*/
|
|
24
|
+
import { createServer } from "node:http";
|
|
25
|
+
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { fetch as undiciFetch, ProxyAgent } from "undici";
|
|
30
|
+
const CONFIG_DIR = join(homedir(), ".clawmoney");
|
|
31
|
+
const FINGERPRINT_PATH = join(CONFIG_DIR, "gemini-fingerprint.json");
|
|
32
|
+
export function hasGeminiFingerprint() {
|
|
33
|
+
return existsSync(FINGERPRINT_PATH);
|
|
34
|
+
}
|
|
35
|
+
const HOP_BY_HOP = new Set([
|
|
36
|
+
"host",
|
|
37
|
+
"connection",
|
|
38
|
+
"content-length",
|
|
39
|
+
"transfer-encoding",
|
|
40
|
+
"accept-encoding",
|
|
41
|
+
]);
|
|
42
|
+
function cloneHeaders(src) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [k, v] of Object.entries(src)) {
|
|
45
|
+
if (v == null)
|
|
46
|
+
continue;
|
|
47
|
+
if (HOP_BY_HOP.has(k.toLowerCase()))
|
|
48
|
+
continue;
|
|
49
|
+
out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
// /v1beta and /v1alpha go to generativelanguage (AI Studio).
|
|
54
|
+
// Everything else (notably /v1internal for Code Assist) goes to
|
|
55
|
+
// cloudcode-pa. Matches the manual script's routing.
|
|
56
|
+
function resolveUpstreamURL(path) {
|
|
57
|
+
if (path.startsWith("/v1beta") ||
|
|
58
|
+
path.startsWith("/v1/beta") ||
|
|
59
|
+
path.startsWith("/v1alpha")) {
|
|
60
|
+
return `https://generativelanguage.googleapis.com${path}`;
|
|
61
|
+
}
|
|
62
|
+
return `https://cloudcode-pa.googleapis.com${path}`;
|
|
63
|
+
}
|
|
64
|
+
function extractFingerprint(body, headers) {
|
|
65
|
+
if (!body || typeof body !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
const projectRaw = body.project;
|
|
68
|
+
const projectId = typeof projectRaw === "string" ? projectRaw.trim() : "";
|
|
69
|
+
// :loadCodeAssist carries {metadata} without a project — wait for
|
|
70
|
+
// the next request that does.
|
|
71
|
+
if (!projectId)
|
|
72
|
+
return null;
|
|
73
|
+
const uaRaw = headers["user-agent"];
|
|
74
|
+
const ua = (Array.isArray(uaRaw) ? uaRaw.join(", ") : (uaRaw ?? "")).trim();
|
|
75
|
+
const versionMatch = ua.match(/GeminiCLI\/(\d+\.\d+[.\d]*)/i);
|
|
76
|
+
const cliVersion = versionMatch ? versionMatch[1] : "unknown";
|
|
77
|
+
const xGoogRaw = headers["x-goog-api-client"];
|
|
78
|
+
const xGoog = (Array.isArray(xGoogRaw) ? xGoogRaw.join(", ") : (xGoogRaw ?? "")).trim();
|
|
79
|
+
return {
|
|
80
|
+
project_id: projectId,
|
|
81
|
+
cli_version: cliVersion,
|
|
82
|
+
user_agent: ua || `GeminiCLI/${cliVersion}`,
|
|
83
|
+
x_goog_api_client: xGoog || "gl-node/unknown",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function scrubCaptureFiles() {
|
|
87
|
+
try {
|
|
88
|
+
for (const f of readdirSync(CONFIG_DIR)) {
|
|
89
|
+
if (/^capture-gemini-\d+\.json$/.test(f)) {
|
|
90
|
+
unlinkSync(join(CONFIG_DIR, f));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// ignore — best-effort
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function bootstrapGeminiFingerprint(opts = {}) {
|
|
99
|
+
const timeoutMs = opts.timeoutMs ?? 45_000;
|
|
100
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
101
|
+
if (hasGeminiFingerprint()) {
|
|
102
|
+
throw new Error("gemini-fingerprint.json already exists — delete it to re-bootstrap");
|
|
103
|
+
}
|
|
104
|
+
// Gemini talks to Google — Google is reachable only through a
|
|
105
|
+
// proxy from GFW-side networks, so we DO honor HTTPS_PROXY for the
|
|
106
|
+
// upstream forward. The child subprocess gets no proxy env because
|
|
107
|
+
// it's talking to 127.0.0.1 (us), and routing 127.0.0.1 through
|
|
108
|
+
// http_proxy tends to wedge.
|
|
109
|
+
const proxyUrl = process.env.HTTPS_PROXY ||
|
|
110
|
+
process.env.https_proxy ||
|
|
111
|
+
process.env.HTTP_PROXY ||
|
|
112
|
+
process.env.http_proxy;
|
|
113
|
+
let upstreamDispatcher;
|
|
114
|
+
if (proxyUrl && /^https?:\/\//.test(proxyUrl)) {
|
|
115
|
+
upstreamDispatcher = new ProxyAgent(proxyUrl);
|
|
116
|
+
}
|
|
117
|
+
let server = null;
|
|
118
|
+
let geminiChild = null;
|
|
119
|
+
let resolved = false;
|
|
120
|
+
let capturedFp = null;
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
if (geminiChild && !geminiChild.killed) {
|
|
123
|
+
try {
|
|
124
|
+
geminiChild.kill("SIGTERM");
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (server) {
|
|
131
|
+
try {
|
|
132
|
+
server.close();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// ignore
|
|
136
|
+
}
|
|
137
|
+
server = null;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
if (resolved)
|
|
143
|
+
return;
|
|
144
|
+
resolved = true;
|
|
145
|
+
cleanup();
|
|
146
|
+
reject(new Error(`gemini fingerprint capture timed out after ${timeoutMs}ms`));
|
|
147
|
+
}, timeoutMs);
|
|
148
|
+
server = createServer((req, res) => {
|
|
149
|
+
const chunks = [];
|
|
150
|
+
req.on("data", (c) => chunks.push(c));
|
|
151
|
+
req.on("end", async () => {
|
|
152
|
+
const bodyBuf = Buffer.concat(chunks);
|
|
153
|
+
const bodyText = bodyBuf.toString("utf-8");
|
|
154
|
+
let parsedBody;
|
|
155
|
+
try {
|
|
156
|
+
parsedBody = JSON.parse(bodyText);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
parsedBody = bodyText;
|
|
160
|
+
}
|
|
161
|
+
const isGenerate = req.method === "POST" &&
|
|
162
|
+
typeof req.url === "string" &&
|
|
163
|
+
(req.url.includes("generateContent") || req.url.includes("v1internal"));
|
|
164
|
+
if (!capturedFp &&
|
|
165
|
+
isGenerate &&
|
|
166
|
+
parsedBody &&
|
|
167
|
+
typeof parsedBody === "object") {
|
|
168
|
+
const fp = extractFingerprint(parsedBody, req.headers);
|
|
169
|
+
if (fp) {
|
|
170
|
+
capturedFp = fp;
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(FINGERPRINT_PATH, JSON.stringify(fp, null, 2), "utf-8");
|
|
173
|
+
scrubCaptureFiles();
|
|
174
|
+
}
|
|
175
|
+
catch (writeErr) {
|
|
176
|
+
if (!resolved) {
|
|
177
|
+
resolved = true;
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
cleanup();
|
|
180
|
+
reject(new Error(`failed to write gemini fingerprint: ${writeErr.message}`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Forward to real Google upstream.
|
|
187
|
+
const upstreamURL = resolveUpstreamURL(req.url || "/");
|
|
188
|
+
const targetHost = new URL(upstreamURL).host;
|
|
189
|
+
try {
|
|
190
|
+
const upstreamHeaders = cloneHeaders(req.headers);
|
|
191
|
+
upstreamHeaders["host"] = targetHost;
|
|
192
|
+
const upstreamResp = await undiciFetch(upstreamURL, {
|
|
193
|
+
method: req.method,
|
|
194
|
+
headers: upstreamHeaders,
|
|
195
|
+
body: req.method === "GET" || req.method === "HEAD"
|
|
196
|
+
? undefined
|
|
197
|
+
: bodyBuf,
|
|
198
|
+
dispatcher: upstreamDispatcher,
|
|
199
|
+
});
|
|
200
|
+
const respHeaders = {};
|
|
201
|
+
upstreamResp.headers.forEach((v, k) => {
|
|
202
|
+
const lower = k.toLowerCase();
|
|
203
|
+
if (lower === "content-encoding" ||
|
|
204
|
+
lower === "content-length" ||
|
|
205
|
+
lower === "transfer-encoding")
|
|
206
|
+
return;
|
|
207
|
+
respHeaders[k] = v;
|
|
208
|
+
});
|
|
209
|
+
res.writeHead(upstreamResp.status, respHeaders);
|
|
210
|
+
if (upstreamResp.body) {
|
|
211
|
+
const reader = upstreamResp.body.getReader();
|
|
212
|
+
while (true) {
|
|
213
|
+
const { done: rDone, value } = await reader.read();
|
|
214
|
+
if (rDone)
|
|
215
|
+
break;
|
|
216
|
+
res.write(Buffer.from(value));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
res.end();
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
try {
|
|
223
|
+
res.writeHead(502);
|
|
224
|
+
res.end();
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// ignore
|
|
228
|
+
}
|
|
229
|
+
if (!resolved && !capturedFp) {
|
|
230
|
+
resolved = true;
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
cleanup();
|
|
233
|
+
reject(new Error(`upstream google request failed: ${err.message}`));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (capturedFp && !resolved) {
|
|
238
|
+
resolved = true;
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
cleanup();
|
|
241
|
+
resolve(capturedFp);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
server.on("error", (err) => {
|
|
246
|
+
if (resolved)
|
|
247
|
+
return;
|
|
248
|
+
resolved = true;
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
cleanup();
|
|
251
|
+
reject(new Error(`gemini bootstrap proxy error: ${err.message}`));
|
|
252
|
+
});
|
|
253
|
+
server.listen(0, "127.0.0.1", () => {
|
|
254
|
+
const addr = server.address();
|
|
255
|
+
if (!addr || typeof addr === "string") {
|
|
256
|
+
if (resolved)
|
|
257
|
+
return;
|
|
258
|
+
resolved = true;
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
cleanup();
|
|
261
|
+
reject(new Error("failed to bind gemini capture proxy"));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const port = addr.port;
|
|
265
|
+
// Strip upstream proxy env vars from the child so it hits our
|
|
266
|
+
// local 127.0.0.1 listener directly. NO_PROXY is belt-and-braces.
|
|
267
|
+
const childEnv = {
|
|
268
|
+
...process.env,
|
|
269
|
+
CODE_ASSIST_ENDPOINT: `http://127.0.0.1:${port}`,
|
|
270
|
+
NO_PROXY: "127.0.0.1,localhost",
|
|
271
|
+
no_proxy: "127.0.0.1,localhost",
|
|
272
|
+
};
|
|
273
|
+
delete childEnv.HTTPS_PROXY;
|
|
274
|
+
delete childEnv.https_proxy;
|
|
275
|
+
delete childEnv.HTTP_PROXY;
|
|
276
|
+
delete childEnv.http_proxy;
|
|
277
|
+
delete childEnv.ALL_PROXY;
|
|
278
|
+
delete childEnv.all_proxy;
|
|
279
|
+
geminiChild = spawn("gemini", ["-p", "hi"], {
|
|
280
|
+
env: childEnv,
|
|
281
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
282
|
+
shell: process.platform === "win32",
|
|
283
|
+
});
|
|
284
|
+
let stderrBuf = "";
|
|
285
|
+
geminiChild.stderr?.on("data", (chunk) => {
|
|
286
|
+
stderrBuf += chunk.toString();
|
|
287
|
+
if (stderrBuf.length > 4_000) {
|
|
288
|
+
stderrBuf = stderrBuf.slice(-4_000);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
geminiChild.stdout?.on("data", () => {
|
|
292
|
+
// drain
|
|
293
|
+
});
|
|
294
|
+
geminiChild.on("error", (err) => {
|
|
295
|
+
if (resolved)
|
|
296
|
+
return;
|
|
297
|
+
resolved = true;
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
cleanup();
|
|
300
|
+
reject(new Error(`failed to spawn gemini: ${err.message} (is the gemini CLI installed and in PATH?)`));
|
|
301
|
+
});
|
|
302
|
+
geminiChild.on("exit", (code) => {
|
|
303
|
+
setTimeout(() => {
|
|
304
|
+
if (capturedFp || resolved)
|
|
305
|
+
return;
|
|
306
|
+
resolved = true;
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
cleanup();
|
|
309
|
+
const tail = stderrBuf.trim().slice(-400);
|
|
310
|
+
const detail = tail ? ` stderr: ${tail}` : "";
|
|
311
|
+
reject(new Error(`gemini -p hi exited with code ${code ?? "unknown"} before sending a v1internal request.${detail}`));
|
|
312
|
+
}, 500);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|