claude-cn-flag-check 0.1.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/bin/cli.js ADDED
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { checkLive, getSignalLists, CaptureError } = require('../src/index');
5
+
6
+ const USAGE = `claude-cn-flag-check — Claude Code 会不会把你的环境标记成「中国用户」?
7
+
8
+ 默认进行【真实检测】:实际运行一次 claude,捕获它真正发出的 system prompt,
9
+ 读取其中隐写的地区标记(而非计算预测)。为保真会临时映射 /etc/hosts 把真实
10
+ 中转主机名指向本地(需 root,自动备份+还原),并用临时配置隔离,不碰你的真实
11
+ ~/.claude,不使用真实凭证(本地拦截)。
12
+
13
+ 用法:
14
+ claude-cn-flag-check [选项]
15
+
16
+ 选项:
17
+ --no-capture 不运行 claude,改用从二进制复刻的逻辑做计算预测
18
+ --base-url <url> 检查一个假设的 ANTHROPIC_BASE_URL(what-if,计算预测)
19
+ --tz <zone> 检查一个假设的 IANA 时区(what-if,计算预测)
20
+ --assume-first-party 模拟 _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1
21
+ --snapshot 使用内置清单快照,跳过从二进制实时提取
22
+ --binary <path> 指定用于提取清单的 claude 二进制路径
23
+ --show-list 打印解码出的域名 / AI 实验室关键词清单
24
+ --json 机器可读输出(含 observed / capture.mode)
25
+ --no-anim 关闭扫描动画
26
+ -h, --help 显示帮助
27
+ -v, --version 显示版本
28
+
29
+ 退出码:0 = 未标记(安全),1 = 会被识别为中国请求,2 = 真实捕获失败。`;
30
+
31
+ function parseArgs(argv) {
32
+ const opts = {};
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ switch (a) {
36
+ case '-h':
37
+ case '--help':
38
+ opts.help = true;
39
+ break;
40
+ case '-v':
41
+ case '--version':
42
+ opts.version = true;
43
+ break;
44
+ case '--json':
45
+ opts.json = true;
46
+ break;
47
+ case '--show-list':
48
+ opts.showList = true;
49
+ break;
50
+ case '--snapshot':
51
+ opts.preferSnapshot = true;
52
+ break;
53
+ case '--assume-first-party':
54
+ opts.assumeFirstParty = true;
55
+ break;
56
+ case '--no-anim':
57
+ opts.noAnim = true;
58
+ break;
59
+ case '--no-capture':
60
+ opts.noCapture = true;
61
+ break;
62
+ case '--base-url':
63
+ opts.baseUrl = argv[++i];
64
+ break;
65
+ case '--tz':
66
+ opts.timezone = argv[++i];
67
+ break;
68
+ case '--binary':
69
+ opts.binaryPath = argv[++i];
70
+ break;
71
+ default:
72
+ process.stderr.write(`未知选项: ${a}\n`);
73
+ process.exit(2);
74
+ }
75
+ }
76
+ return opts;
77
+ }
78
+
79
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
80
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
81
+ const red = (s) => c('31', s);
82
+ const green = (s) => c('32', s);
83
+ const yellow = (s) => c('33', s);
84
+ const cyan = (s) => c('36', s);
85
+ const dim = (s) => c('2', s);
86
+ const bold = (s) => c('1', s);
87
+
88
+ const w = (s) => process.stdout.write(s);
89
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
90
+
91
+ // 分档 → 中文标签 / 颜色
92
+ const LEVEL = {
93
+ safe: { label: '安全', color: green, bar: green },
94
+ low: { label: '低', color: green, bar: green },
95
+ medium: { label: '中', color: yellow, bar: yellow },
96
+ high: { label: '高', color: red, bar: red },
97
+ };
98
+
99
+ // 各信号的中文名与修复建议(展示层持有,detect 只给数据)
100
+ const SIGNAL_INFO = {
101
+ cnTZ: {
102
+ name: '系统时区',
103
+ hint: '中国时区,会把日期分隔符 - 改成 /',
104
+ fix: '用非中国时区启动:TZ=Asia/Tokyo claude(时区挂代理也会泄露,这一招最有效)',
105
+ },
106
+ known: {
107
+ name: '中转域名清单',
108
+ hint: '中转主机命中内置清单,撇号被改成 U+2019',
109
+ fix: '换一个不在清单、且非 *.cn 或其子域的中转主机',
110
+ },
111
+ labKw: {
112
+ name: 'AI 实验室关键词',
113
+ hint: '中转主机含中国 AI 实验室关键词,撇号被改成 U+02BC',
114
+ fix: '换一个主机名不含 AI 实验室关键词的中转',
115
+ },
116
+ };
117
+
118
+ async function main() {
119
+ const opts = parseArgs(process.argv.slice(2));
120
+ const pkg = require('../package.json');
121
+
122
+ if (opts.help) return w(USAGE + '\n'), 0;
123
+ if (opts.version) return w(pkg.version + '\n'), 0;
124
+
125
+ if (opts.showList) return showList(opts);
126
+
127
+ let report;
128
+ try {
129
+ report = await checkLive({ ...opts, log: (s) => COLOR && !opts.json && process.stderr.write(dim(` … ${s}\n`)) });
130
+ } catch (e) {
131
+ if (e instanceof CaptureError) {
132
+ process.stderr.write(red(`\n✗ 真实捕获失败:${e.message}\n`));
133
+ process.stderr.write(dim(` (code: ${e.code}) 可加 --no-capture 退回计算预测,或用 root 运行。\n`));
134
+ return 2;
135
+ }
136
+ throw e;
137
+ }
138
+
139
+ if (opts.json) {
140
+ w(JSON.stringify(report, null, 2) + '\n');
141
+ return report.marked ? 1 : 0;
142
+ }
143
+
144
+ const animate = COLOR && !opts.noAnim;
145
+ await printReport(report, animate);
146
+ return report.marked ? 1 : 0;
147
+ }
148
+
149
+ function showList(opts) {
150
+ const lists = getSignalLists(opts);
151
+ if (opts.json) {
152
+ w(JSON.stringify(lists, null, 2) + '\n');
153
+ return 0;
154
+ }
155
+ w(`来源: ${lists.source}${lists.version ? ` (v${lists.version})` : ''} XOR密钥=${lists.xorKey}\n`);
156
+ w(`\n域名清单(${lists.domains.length} 条):\n`);
157
+ w(' ' + lists.domains.join('\n ') + '\n');
158
+ w(`\nAI 实验室关键词(${lists.labKeywords.length} 条):\n`);
159
+ w(' ' + lists.labKeywords.join('\n ') + '\n');
160
+ return 0;
161
+ }
162
+
163
+ async function printReport(r, animate) {
164
+ w(bold('claude-cn-flag-check') + dim(' Claude Code 中国用户标记自查\n'));
165
+ w(
166
+ dim(
167
+ `清单来源: ${r.lists.source}${r.lists.version ? ` v${r.lists.version}` : ''} · ` +
168
+ `${r.lists.domainCount} 域名 / ${r.lists.keywordCount} 关键词\n\n`,
169
+ ),
170
+ );
171
+
172
+ w(bold('环境(按真实配置解析,非仅进程环境变量)\n'));
173
+ const tzMark = r.overrides.timezone ? dim(' [命令行覆盖]') : dim(` ← ${r.resolution.timezone.source || '未知来源'}`);
174
+ const buMark = r.overrides.baseUrl
175
+ ? dim(' [命令行覆盖]')
176
+ : dim(` ← ${r.resolution.baseUrl.source || '所有来源均未设置 → 第一方'}`);
177
+ w(` 系统时区 ${r.input.timezone || dim('(未知)')}${tzMark}\n`);
178
+ w(` ANTHROPIC_BASE_URL ${r.input.baseUrl || dim('(未设置)')}${buMark}\n`);
179
+ if (r.input.host) w(` 中转主机 ${r.input.host}\n`);
180
+ w('\n');
181
+
182
+ // ——— 判定依据:逐条列出检查过的配置来源 ———
183
+ renderEvidence(r);
184
+
185
+ // ——— 检测项:先全部列出为「待检测」,再逐项刷新结果 ———
186
+ const items = buildChecks(r);
187
+ w(bold(`检测项(${items.length} 项区域信号)\n`));
188
+ await runChecks(items, animate);
189
+ w('\n');
190
+
191
+ // ——— 风险分(count-up 动画) ———
192
+ await renderScore(r, animate);
193
+
194
+ // ——— 隐写载荷 ———
195
+ const capMode = r.capture && r.capture.mode;
196
+ const capLabel =
197
+ capMode === 'observed'
198
+ ? green('真实捕获 ✓')
199
+ : capMode === 'first-party'
200
+ ? green('第一方端点 · 确定安全')
201
+ : yellow('计算预测');
202
+ w(bold('实际发出的载体行') + ' ' + capLabel + '\n');
203
+ if (r.capture && r.capture.reason) w(dim(` ← ${r.capture.reason}\n`));
204
+ w(` ${cyan(JSON.stringify(r.carrier.dateLine))}\n`);
205
+ w(dim(` 撇号 ${r.carrier.apostropheCodepoint}`));
206
+ w(dim(r.carrier.separatorSwapped ? ',分隔符 - → /\n' : '\n'));
207
+ w(dim(` hex: ${r.carrier.dateLineHex}\n\n`));
208
+
209
+ // ——— 判定与修复 ———
210
+ renderVerdict(r);
211
+ }
212
+
213
+ // 逐条打印配置解析依据(用户可见「我们凭什么这么判」)
214
+ function renderEvidence(r) {
215
+ if (r.overrides.baseUrl && r.overrides.timezone) return; // 全手动覆盖,无需依据
216
+ w(bold('判定依据(检查了这些真实来源)\n'));
217
+
218
+ const line = (e) => {
219
+ const key = e.key ? ` ${e.key}` : '';
220
+ switch (e.status) {
221
+ case 'set':
222
+ return ` ${green('✓')} ${e.source}${key} = ${cyan(maskUrl(e.value))}${e.note ? dim(' · ' + e.note) : ''}`;
223
+ case 'absent':
224
+ return ` ${dim('·')} ${e.source}${dim(`${key} 未设置`)}`;
225
+ case 'missing':
226
+ return ` ${dim('·')} ${e.source} ${dim('文件不存在')}`;
227
+ default:
228
+ return ` ${dim('·')} ${e.source}`;
229
+ }
230
+ };
231
+
232
+ if (!r.overrides.baseUrl) {
233
+ w(dim(' ANTHROPIC_BASE_URL:\n'));
234
+ for (const e of r.resolution.baseUrl.evidence) w(line(e) + '\n');
235
+ }
236
+ if (!r.overrides.timezone) {
237
+ w(dim(' 时区(TZ / 系统):\n'));
238
+ for (const e of r.resolution.timezone.evidence) w(line(e) + '\n');
239
+ }
240
+ w('\n');
241
+ }
242
+
243
+ function maskUrl(v) {
244
+ return v; // base URL 无敏感信息;token 类不在解析范围内,不会出现在这里
245
+ }
246
+
247
+ // 构造检测项列表(含前置端点门)
248
+ function buildChecks(r) {
249
+ const items = [];
250
+ items.push({
251
+ name: '端点类型',
252
+ weight: null,
253
+ // 命中=第三方(会触发检测)。第一方则整套跳过。
254
+ status: r.firstParty ? 'safe' : 'gate',
255
+ detailSafe: '第一方端点(官方 / 未设 base_url / 逃逸开关),整套检测跳过',
256
+ detailGate: '第三方中转端点,触发地区检测',
257
+ });
258
+ for (const id of ['cnTZ', 'known', 'labKw']) {
259
+ const info = SIGNAL_INFO[id];
260
+ let status;
261
+ if (r.firstParty) status = 'skip';
262
+ else status = r.signals[id] ? 'hit' : 'clear';
263
+ items.push({ id, name: info.name, weight: r.weights[id], status, hint: info.hint });
264
+ }
265
+ return items;
266
+ }
267
+
268
+ async function runChecks(items, animate) {
269
+ const pending = (it) =>
270
+ ` ${dim('◌')} ${it.name}${it.weight != null ? dim(` (权重 ${it.weight})`) : ''} ${dim('…')}`;
271
+ const resolved = (it) => {
272
+ const wt = it.weight != null ? dim(` (权重 ${it.weight})`) : '';
273
+ switch (it.status) {
274
+ case 'gate':
275
+ return ` ${yellow('⚑')} ${it.name}${wt} ${yellow('第三方中转')} ${dim('· ' + it.detailGate)}`;
276
+ case 'safe':
277
+ return ` ${green('✓')} ${it.name}${wt} ${green('第一方端点')} ${dim('· ' + it.detailSafe)}`;
278
+ case 'skip':
279
+ return ` ${dim('–')} ${it.name}${wt} ${dim('跳过')}`;
280
+ case 'hit':
281
+ return ` ${red('✗')} ${it.name}${wt} ${red('命中')} ${dim('· ' + it.hint)}`;
282
+ case 'clear':
283
+ return ` ${green('✓')} ${it.name}${wt} ${green('未命中')}`;
284
+ }
285
+ };
286
+
287
+ if (!animate) {
288
+ for (const it of items) w(resolved(it) + '\n');
289
+ return;
290
+ }
291
+ // 先全部打印为待检测
292
+ for (const it of items) w(pending(it) + '\n');
293
+ // 光标上移 N 行,逐项清行重写
294
+ w(`\x1b[${items.length}A`);
295
+ for (const it of items) {
296
+ await sleep(240);
297
+ w('\x1b[2K\r' + resolved(it) + '\n');
298
+ }
299
+ }
300
+
301
+ async function renderScore(r, animate) {
302
+ const lv = LEVEL[r.level];
303
+ const width = 24;
304
+ const drawBar = (val) => {
305
+ const filled = Math.round((val / 100) * width);
306
+ return lv.bar('█'.repeat(filled)) + dim('░'.repeat(width - filled));
307
+ };
308
+ const line = (val) =>
309
+ ` ${bold('风险分')} ${lv.color(bold(String(val).padStart(3)))}${dim('/100')} ${drawBar(val)} ${lv.color('[' + lv.label + ']')}`;
310
+
311
+ if (animate && r.score > 0) {
312
+ const steps = 16;
313
+ for (let i = 1; i <= steps; i++) {
314
+ const val = Math.round((r.score * i) / steps);
315
+ w('\r' + line(val));
316
+ await sleep(28);
317
+ }
318
+ w('\r' + line(r.score) + '\n\n');
319
+ } else {
320
+ w(line(r.score) + '\n\n');
321
+ }
322
+ }
323
+
324
+ function renderVerdict(r) {
325
+ if (r.firstParty) {
326
+ w(green(bold('✓ 安全:第一方端点,无论时区如何都不会被打标记。\n')));
327
+ return;
328
+ }
329
+ if (!r.marked) {
330
+ w(green(bold('✓ 未标记:走中转,但未命中任何中国信号。\n')));
331
+ return;
332
+ }
333
+
334
+ const chance = { low: '低', medium: '中', high: '高' }[r.level];
335
+ w(red(bold('✗ 会被识别为中国大陆请求。\n')));
336
+ w(` 进入封号风控队列的可能性:${LEVEL[r.level].color(bold(chance))}\n`);
337
+ const parts = [];
338
+ if (r.carrier.apostropheCodepoint !== 'U+0027') parts.push(`撇号 ${r.carrier.apostropheCodepoint}`);
339
+ if (r.carrier.separatorSwapped) parts.push('斜杠日期');
340
+ w(dim(` 服务器读回:${parts.join(' + ')} → 组成 2–3 bit 中国标记\n\n`));
341
+
342
+ w(bold('触发的特征与修复\n'));
343
+ for (const f of r.features) {
344
+ const info = SIGNAL_INFO[f.id];
345
+ w(` ${yellow('•')} [${info.name} · 权重 ${r.weights[f.id]}] ${info.hint}\n`);
346
+ w(` ${dim('修复:')} ${info.fix}\n`);
347
+ }
348
+ w('\n');
349
+ w(
350
+ dim(
351
+ '逃逸开关:_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1 可彻底关闭检测\n' +
352
+ '(告诉客户端把你的 base_url 当作第一方)。依赖前请了解其影响。\n',
353
+ ),
354
+ );
355
+ }
356
+
357
+ main()
358
+ .then((code) => process.exit(code))
359
+ .catch((err) => {
360
+ process.stderr.write(String(err && err.stack ? err.stack : err) + '\n');
361
+ process.exit(2);
362
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "claude-cn-flag-check",
3
+ "version": "0.1.0",
4
+ "description": "Check whether Claude Code would covertly flag your environment as a China user (timezone + relay-hostname region marking). Reproduces the client's bundled detection logic for self-audit.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "anthropic",
9
+ "privacy",
10
+ "steganography",
11
+ "region-detection",
12
+ "self-check",
13
+ "cli"
14
+ ],
15
+ "homepage": "https://github.com/shellus/claude-cn-flag-check#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/shellus/claude-cn-flag-check/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/shellus/claude-cn-flag-check.git"
22
+ },
23
+ "license": "MIT",
24
+ "author": "shellus <353358601@qq.com>",
25
+ "type": "commonjs",
26
+ "main": "src/index.js",
27
+ "bin": {
28
+ "claude-cn-flag-check": "bin/cli.js"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "src/",
33
+ "README.md",
34
+ "README.zh-CN.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "test": "node --test",
39
+ "start": "node bin/cli.js"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ }
44
+ }
package/src/capture.js ADDED
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * REAL capture: run the actual Claude Code binary once against a local endpoint
5
+ * and read the system prompt it genuinely emits, so the marker is *observed*,
6
+ * not predicted.
7
+ *
8
+ * To keep the marker faithful we must let Claude Code see the real relay
9
+ * hostname (the apostrophe depends on it) while the request lands on our local
10
+ * server. We do that by:
11
+ * 1. writing a throwaway CLAUDE_CONFIG_DIR whose settings.json points
12
+ * ANTHROPIC_BASE_URL at http://<realHost>:<port>,
13
+ * 2. temporarily mapping <realHost> → 127.0.0.1 in /etc/hosts (backed up and
14
+ * restored, with signal handlers as a safety net),
15
+ * 3. capturing the POST /v1/messages body and extracting `Today's date is …`.
16
+ *
17
+ * No real credentials are used (we intercept before anything leaves the box)
18
+ * and nothing in the user's real ~/.claude is touched.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const net = require('net');
24
+ const path = require('path');
25
+ const http = require('http');
26
+ const { spawn } = require('child_process');
27
+ const { resolveBinaryPath } = require('./extract');
28
+
29
+ const HOSTS_PATH = '/etc/hosts';
30
+ const MARKER = '# claude-cn-flag-check (temporary — auto-removed)';
31
+
32
+ function getFreePort() {
33
+ return new Promise((resolve, reject) => {
34
+ const srv = net.createServer();
35
+ srv.listen(0, '127.0.0.1', () => {
36
+ const p = srv.address().port;
37
+ srv.close(() => resolve(p));
38
+ });
39
+ srv.on('error', reject);
40
+ });
41
+ }
42
+
43
+ function parseCarrier(body) {
44
+ // Match Today<apostrophe>s date is <date>. across the raw JSON body.
45
+ const m = body.match(/Today(.)s date is (\d{4}[/-]\d{2}[/-]\d{2})/);
46
+ if (!m) return null;
47
+ const apostrophe = m[1];
48
+ const date = m[2];
49
+ return { apostrophe, date, separatorSwapped: date.includes('/'), dateLine: `Today${apostrophe}s date is ${date}.` };
50
+ }
51
+
52
+ function readHosts() {
53
+ try {
54
+ return fs.readFileSync(HOSTS_PATH, 'utf8');
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function needsHostsEntry(host) {
61
+ if (!host || host === '127.0.0.1' || host === 'localhost') return false;
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Run one real capture.
67
+ *
68
+ * @param {object} o
69
+ * @param {string} o.host real relay hostname to preserve in the marker
70
+ * @param {string} o.baseUrl the real ANTHROPIC_BASE_URL (for the model/scheme)
71
+ * @param {string=} o.model model to request (from real settings if any)
72
+ * @param {number=} o.timeoutMs default 30000
73
+ * @param {(s:string)=>void=} o.log progress logger
74
+ * @returns {Promise<{apostrophe,date,separatorSwapped,dateLine,raw,host}>}
75
+ */
76
+ async function captureReal(o) {
77
+ const host = o.host;
78
+ const log = o.log || (() => {});
79
+ const timeoutMs = o.timeoutMs || 30000;
80
+
81
+ const claudeBin = resolveBinaryPath();
82
+ if (!claudeBin) throw new CaptureError('未找到 claude 二进制(claude 未安装或不在 PATH)', 'no-binary');
83
+
84
+ const spoof = needsHostsEntry(host);
85
+ if (spoof) {
86
+ const hosts = readHosts();
87
+ if (hosts == null) throw new CaptureError(`无法读取 ${HOSTS_PATH}`, 'hosts-read');
88
+ try {
89
+ fs.accessSync(HOSTS_PATH, fs.constants.W_OK);
90
+ } catch {
91
+ throw new CaptureError(`需要写入 ${HOSTS_PATH} 以保真捕获(请用 root 运行,或加 --no-capture 退回计算预测)`, 'hosts-perm');
92
+ }
93
+ }
94
+
95
+ const port = await getFreePort();
96
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cnflag-cap-'));
97
+ const configDir = path.join(tmpDir, '.claude');
98
+ fs.mkdirSync(configDir, { recursive: true });
99
+ fs.writeFileSync(
100
+ path.join(configDir, 'settings.json'),
101
+ JSON.stringify({ env: { ANTHROPIC_BASE_URL: `http://${host}:${port}`, ANTHROPIC_AUTH_TOKEN: 'sk-cnflag-local-capture' } }),
102
+ );
103
+
104
+ let hostsBackup = null;
105
+ let child = null;
106
+ let server = null;
107
+ const cleanup = () => {
108
+ if (hostsBackup != null) {
109
+ try {
110
+ fs.writeFileSync(HOSTS_PATH, hostsBackup);
111
+ } catch {
112
+ /* best effort */
113
+ }
114
+ hostsBackup = null;
115
+ }
116
+ if (child) {
117
+ try {
118
+ child.kill('SIGKILL');
119
+ } catch {
120
+ /* ignore */
121
+ }
122
+ }
123
+ if (server) {
124
+ try {
125
+ server.close();
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ }
130
+ try {
131
+ fs.rmSync(tmpDir, { recursive: true, force: true });
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ };
136
+ const onSignal = () => {
137
+ cleanup();
138
+ process.exit(130);
139
+ };
140
+ process.on('SIGINT', onSignal);
141
+ process.on('SIGTERM', onSignal);
142
+
143
+ try {
144
+ if (spoof) {
145
+ hostsBackup = readHosts();
146
+ fs.writeFileSync(HOSTS_PATH, hostsBackup.replace(/\n?$/, '\n') + `127.0.0.1 ${host} ${MARKER}\n`);
147
+ log(`临时映射 ${host} → 127.0.0.1(/etc/hosts)`);
148
+ }
149
+
150
+ const captured = await new Promise((resolve, reject) => {
151
+ const timer = setTimeout(() => reject(new CaptureError('捕获超时(claude 未在时限内发出请求)', 'timeout')), timeoutMs);
152
+
153
+ server = http.createServer((req, res) => {
154
+ let b = '';
155
+ req.on('data', (c) => (b += c));
156
+ req.on('end', () => {
157
+ if (req.method === 'POST' && b.length > 0) {
158
+ const parsed = parseCarrier(b);
159
+ if (parsed) {
160
+ clearTimeout(timer);
161
+ resolve({ ...parsed, raw: b, host });
162
+ }
163
+ }
164
+ if (req.url && req.url.includes('messages')) {
165
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
166
+ res.end(
167
+ 'event: message_start\ndata: {"type":"message_start","message":{"id":"x","type":"message","role":"assistant","model":"x","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n',
168
+ );
169
+ } else {
170
+ res.writeHead(200, { 'content-type': 'application/json' });
171
+ res.end('{}');
172
+ }
173
+ });
174
+ });
175
+ server.on('error', reject);
176
+ server.listen(port, '127.0.0.1', () => {
177
+ log('本地捕获端点就绪,启动 claude…');
178
+ const args = ['-p', 'ping', '--output-format', 'text'];
179
+ if (o.model) args.push('--model', o.model);
180
+ child = spawn(claudeBin, args, {
181
+ env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, ANTHROPIC_BASE_URL: `http://${host}:${port}` },
182
+ stdio: ['ignore', 'ignore', 'ignore'],
183
+ });
184
+ child.on('error', (e) => reject(new CaptureError(`启动 claude 失败: ${e.message}`, 'spawn')));
185
+ });
186
+ });
187
+
188
+ return captured;
189
+ } finally {
190
+ process.removeListener('SIGINT', onSignal);
191
+ process.removeListener('SIGTERM', onSignal);
192
+ cleanup();
193
+ }
194
+ }
195
+
196
+ class CaptureError extends Error {
197
+ constructor(message, code) {
198
+ super(message);
199
+ this.name = 'CaptureError';
200
+ this.code = code;
201
+ }
202
+ }
203
+
204
+ module.exports = { captureReal, CaptureError, parseCarrier };