autowonder 0.3.23 → 0.3.25

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/README.md CHANGED
@@ -6,10 +6,10 @@ AutoWonder 本地 agent runtime。安装后启动 daemon,持续轮询本地 as
6
6
 
7
7
  ```bash
8
8
  # Qoder CLI(模型使用 provider model ID;Context Window 使用固定档位)
9
- npx -y autowonder@0.3.23 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
9
+ npx -y autowonder@0.3.25 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
10
10
 
11
11
  # Qoder CLI CN
12
- npx -y autowonder@0.3.23 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
12
+ npx -y autowonder@0.3.25 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
13
13
  ```
14
14
 
15
15
  `connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID 建立连接。客户端只支持通过 Qoder 系列 CLI 启动(`qoder` 对应 `qodercli`,`qodercn` 对应 `qodercn`),其他 `--provider` 取值会在启动 daemon 前直接报错拦截并提示 `Unsupported provider`。选择 Qoder CLI 或 Qoder CLI CN 时需要 Node.js 20+;runtime 会在缺少对应 CLI 时自动通过 npm 安装,并在尚未登录时打开浏览器登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
@@ -27,9 +27,9 @@ runtime 启动时始终只拉取一次 Platform 环境变量快照,即使已
27
27
 
28
28
  出于安全考虑,命令行 token 可能出现在 shell 历史和操作系统进程列表中,建议优先使用环境变量或 Platform 绑定。
29
29
 
30
- `connect` 和 `start` 都要求 Qoder CLI / Qoder CLI CN 版本至少为 `1.1.65`。检测到旧版或无法解析的版本时,runtime 会对当前优先选中的用户 CLI 执行内置 `update`,原位升级到最新版并再次强校验;升级失败或升级后仍不满足最低版本时拒绝启动 daemon。所有 Qoder 系列子进程(普通任务、conversation、warm 会话、恢复回退、命令与模型探测)都会强制注入固定的 `QODER_INTEGRATION_ID`,调用方环境不能覆盖该流量归因标识。
30
+ `connect` 和 `start` 都要求 Qoder CLI / Qoder CLI CN 版本至少为 `1.1.65`。检测到旧版、无法解析的版本,或共享安装被其他进程的安装锁占用时,runtime 不再原位升级共享 CLI,而是安装到独立的私有 generation(`~/.autowonder/qoder-generations/<provider>/gen-*`):先校验候选二进制版本,再原子发布 `current.json` 指向已验证的 generation,启动时直接使用该 generation 的具体路径。候选安装或校验失败不会被选中,上一代保留为回退;旧 generation 不自动删除。显式指定的外部可执行文件(`AUTOWONDER_QODER_CLI` / `AUTOWONDER_QODERCN_CLI`)在校验通过后仍直接使用,runtime 不会原地修改它。daemon 退出与 re-exec 前会取消并等待进行中的 Qoder 安装/升级维护任务,避免遗留共享安装票据让同机其他执行器的版本预检持续失败。所有 Qoder 系列子进程(普通任务、conversation、warm 会话、恢复回退、命令与模型探测)都会强制注入固定的 `QODER_INTEGRATION_ID`,调用方环境不能覆盖该流量归因标识。
31
31
 
32
- `qodercn` 是国内版 Qoder CLI。runtime 会在缺少 `qodercn` 时自动把 `@qodercn-ai/qoderclicn` 安装到 `~/.autowonder/qoder-cn-cli`,并在尚未登录时打开浏览器登录。它使用自己的配置根(`QODERCN_CONFIG_DIR`,默认 `~/.qoder-cn`),与国际版 `qoder` 不共享可执行文件、凭证和会话。可用 `AUTOWONDER_QODERCN_CLI` 指定自定义可执行文件路径。
32
+ `qodercn` 是国内版 Qoder CLI。runtime 会在缺少 `qodercn` 时自动把 `@qodercn-ai/qoderclicn` 安装到独立 generation `~/.autowonder/qoder-generations/qodercn/`(旧版本写入的 `~/.autowonder/qoder-cn-cli` 安装仍会被识别),并在尚未登录时打开浏览器登录。它使用自己的配置根(`QODERCN_CONFIG_DIR`,默认 `~/.qoder-cn`),与国际版 `qoder` 不共享可执行文件、凭证和会话。可用 `AUTOWONDER_QODERCN_CLI` 指定自定义可执行文件路径。
33
33
 
34
34
  Qoder 的 Context Window 只能使用 `1000000`、`400000` 或 `260000`,Reasoning Effort 只能使用 `none`、`low`、`medium`、`high`、`xhigh` 或 `max`。`--model` 不再校验固定白名单:取值经 `AUTOWONDER_MODEL` 原样透传给 Qoder CLI,因此服务端上线新模型无需升级本客户端。建议直接复制 AutoWonder 执行器页面生成的命令,避免把模型展示名称误当成 provider model ID。
35
35
 
@@ -177,7 +177,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
177
177
  也可以直接提交到本地 API:
178
178
 
179
179
  ```bash
180
- npx -y autowonder@0.3.23 dispatch ./assignment.json
180
+ npx -y autowonder@0.3.25 dispatch ./assignment.json
181
181
  ```
182
182
 
183
183
  ## 上传工单附件
@@ -185,7 +185,7 @@ npx -y autowonder@0.3.23 dispatch ./assignment.json
185
185
  把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
186
186
 
187
187
  ```bash
188
- npx -y autowonder@0.3.23 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
188
+ npx -y autowonder@0.3.25 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
189
189
  ```
190
190
 
191
191
  上传令牌通过 MCP 工具 `autowonder.workitem_cli_upload_token` 签发,经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 环境变量传入。`--file` 可重复指定多个文件;`--json` 输出机器可读结果。失败时按错误类型返回不同退出码(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
@@ -195,7 +195,7 @@ npx -y autowonder@0.3.23 workitem upload --server-url <autowonder-server-url> --
195
195
  把工单上已有的需求/设计文档下载到本地目录,再用本地文件工具阅读,避免把大文件或二进制附件搬进会话上下文:
196
196
 
197
197
  ```bash
198
- npx -y autowonder@0.3.23 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
198
+ npx -y autowonder@0.3.25 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
199
199
  ```
200
200
 
201
201
  下载令牌通过 MCP 工具 `autowonder.workitem_cli_download_token` 签发(与上传令牌相互独立),经 `--token` 或 `AUTOWONDER_DOWNLOAD_TOKEN` 环境变量传入。`--file` 可重复指定文档名或 artifact id,省略则下载该工单的全部附件;`--output-dir` 默认为当前目录;`--json` 输出机器可读结果,含每个文件的 `id`、`name`、`path`、`size`。退出码约定与 `workitem upload` 相同(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
@@ -205,7 +205,7 @@ npx -y autowonder@0.3.23 workitem download --server-url <autowonder-server-url>
205
205
  把需求/设计文档上传到 7x24 定时任务,用法与工单附件一致(同一套上传令牌与预检规则):
206
206
 
207
207
  ```bash
208
- npx -y autowonder@0.3.23 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
208
+ npx -y autowonder@0.3.25 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
209
209
  ```
210
210
 
211
211
  上传令牌同样通过 `autowonder.workitem_cli_upload_token` 签发(令牌为用户级,对当前可修改的任意定时任务生效),经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 传入;退出码约定与 `workitem upload` 相同。
@@ -213,8 +213,8 @@ npx -y autowonder@0.3.23 scheduled-task upload --server-url <autowonder-server-u
213
213
  ## 管理 daemon
214
214
 
215
215
  ```bash
216
- npx -y autowonder@0.3.23 status
217
- npx -y autowonder@0.3.23 stop
216
+ npx -y autowonder@0.3.25 status
217
+ npx -y autowonder@0.3.25 stop
218
218
  ```
219
219
 
220
220
  默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
@@ -237,7 +237,7 @@ daemon 内置独立于工作区 GC 的保留清扫:默认每小时运行一次
237
237
  ## 调试
238
238
 
239
239
  ```bash
240
- npx -y autowonder@0.3.23 connect ... --debug
240
+ npx -y autowonder@0.3.25 connect ... --debug
241
241
  ```
242
242
 
243
243
  `--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
@@ -5,6 +5,7 @@ const crypto = require("crypto");
5
5
  const fs = require("fs");
6
6
  const os = require("os");
7
7
  const path = require("path");
8
+ const generations = require("./qoder-generations");
8
9
 
9
10
  const { ensureDir, error, findOnPath, log, resolveUserPath } = require("./shared");
10
11
 
@@ -260,6 +261,8 @@ function findQoderExecutable(provider) {
260
261
  error(`${profile.envVar} points at ${resolved}, which is not executable; falling back to PATH.`);
261
262
  }
262
263
  }
264
+ const managed = generations.current(generationOptions(profile));
265
+ if (managed) return managed;
263
266
  const onPath = findOnPath(profile.executable);
264
267
  if (onPath) return onPath;
265
268
  if (IS_WIN32) {
@@ -284,22 +287,38 @@ function managedQoderExecutable(profile) {
284
287
  return path.join(qoderPaths(profile).managedDir, "node_modules", ".bin", binName);
285
288
  }
286
289
 
290
+ function generationOptions(profile) {
291
+ const provider = profile === QODER_FAMILY_PROFILES.qodercn ? "qodercn" : "qoder";
292
+ return {
293
+ root: path.join(os.homedir(), ".autowonder", "qoder-generations", provider),
294
+ executable: IS_WIN32 ? profile.windowsShimName : profile.managedBinName,
295
+ verify: (bin) => qoderVersionMeetsMinimum(probeQoderVersionUntil(profile, bin,
296
+ (probe) => qoderVersionMeetsMinimum(probe.version)).version),
297
+ };
298
+ }
299
+
287
300
  function installQoderCLI(provider, options = {}) {
288
301
  const profile = QODER_FAMILY_PROFILES[provider];
289
- if (options.lockHeld) return installQoderCLIUnlocked(provider);
290
- const result = withQoderUpdateLock(managedQoderExecutable(profile), () => {
291
- if (options.skipIfPresent) {
292
- const existing = findQoderExecutable(provider);
293
- if (existing) return existing;
294
- }
295
- return installQoderCLIUnlocked(provider);
296
- });
297
- return result || "";
302
+ if (options.skipIfPresent) {
303
+ const current = generations.current(generationOptions(profile));
304
+ if (current) return current;
305
+ }
306
+ return installQoderCLIUnlocked(provider);
298
307
  }
299
308
 
300
309
  function installQoderCLIUnlocked(provider) {
301
310
  const profile = QODER_FAMILY_PROFILES[provider];
302
- const managedDir = qoderPaths(profile).managedDir;
311
+ try {
312
+ return generations.install({ ...generationOptions(profile),
313
+ install: (prefix) => installQoderPackage(profile, prefix),
314
+ });
315
+ } catch (err) {
316
+ error(`${profile.displayName} requires version 1.1.65 or newer: ${err.message}`);
317
+ return "";
318
+ }
319
+ }
320
+
321
+ function installQoderPackage(profile, managedDir) {
303
322
  log(`${profile.displayName} is not installed. Installing ${profile.npmPackage}...`);
304
323
  const args = [
305
324
  "install", "--prefix", managedDir, profile.npmPackage, "--no-audit", "--no-fund",
@@ -332,9 +351,7 @@ function installQoderCLIUnlocked(provider) {
332
351
  error(`Failed to install ${profile.displayName} into ${managedDir}: ${detail}`);
333
352
  return "";
334
353
  }
335
- const executable = findQoderExecutable(provider);
336
- if (!executable) error(`${profile.displayName} was installed but ${profile.executable} is not available on PATH.`);
337
- return executable;
354
+ return true;
338
355
  }
339
356
 
340
357
  const MACHINE_ID_REL_PATH = path.join(".auth", "machine_id");
@@ -619,10 +636,10 @@ function validateQoderLockTickets(tickets) {
619
636
  }
620
637
  }
621
638
 
622
- function withQoderUpdateLock(executable, fn) {
639
+ function withQoderUpdateLock(executable, fn, timeoutMs = 15 * 60 * 1000) {
623
640
  const lockPaths = qoderUpdateLockPaths(executable);
624
641
  fs.mkdirSync(path.dirname(lockPaths[0]), { recursive: true, mode: 0o700 });
625
- const deadline = Date.now() + 15 * 60 * 1000;
642
+ const deadline = Date.now() + timeoutMs;
626
643
  const owner = { pid: process.pid, token: crypto.randomBytes(16).toString("hex") };
627
644
  const acquired = [];
628
645
  let failure = null;
@@ -676,48 +693,39 @@ function withQoderUpdateLock(executable, fn) {
676
693
  }
677
694
 
678
695
  function ensureMinimumQoderVersion(profile, provider, executable) {
679
- const result = withQoderUpdateLock(executable, () => ensureMinimumQoderVersionLocked(profile, provider, executable));
680
- return result || { ok: false, version: "unknown", executable };
681
- }
682
-
683
- function ensureMinimumQoderVersionLocked(profile, provider, executable) {
684
- let probe = probeQoderBinaryVersion(executable);
685
- if (isTransientQoderVersionProbe(probe)) {
686
- // An up-to-date CLI answers nothing far more often than it is really too
687
- // old, so confirm the failure before paying for a download.
688
- log(`${profile.displayName} did not report a version on the first probe (${describeQoderVersionProbe(probe)}); re-probing before updating...`);
689
- probe = probeQoderVersionUntil(profile, executable, (candidate) => candidate.outcome === "ok", probe);
690
- }
691
- if (qoderVersionMeetsMinimum(probe.version)) return { ok: true, version: probe.version };
692
-
693
- log(`${profile.displayName} ${probe.version} is older than required version 1.1.65 (or could not be parsed). Updating the current installation to latest...`);
694
- const update = spawnQoderSync(executable, ["update"], { stdio: "inherit", timeout: 5 * 60 * 1000, treeTimeout: true });
695
- if (update.status !== 0) {
696
- const managedDir = qoderPaths(profile).managedDir;
697
- if (executable.startsWith(`${managedDir}${path.sep}`)) {
698
- const reinstalled = installQoderCLI(provider, { lockHeld: true });
699
- if (reinstalled) {
700
- executable = reinstalled;
701
- probe = probeQoderVersionUntil(profile, executable, (candidate) => qoderVersionMeetsMinimum(candidate.version));
702
- if (qoderVersionMeetsMinimum(probe.version)) return { ok: true, version: probe.version, executable };
703
- }
696
+ const opts = generationOptions(profile);
697
+ let probe;
698
+ const inspect = () => {
699
+ probe = probeQoderBinaryVersion(executable);
700
+ if (isTransientQoderVersionProbe(probe)) {
701
+ log(`${profile.displayName} did not report a version; re-probing before selecting another installation...`);
702
+ probe = probeQoderVersionUntil(profile, executable, (candidate) => candidate.outcome === "ok", probe);
704
703
  }
705
- const detail = update.error?.message || (update.signal ? `signal ${update.signal}` : `exit status ${update.status}`);
706
- error(`${profile.displayName} requires version 1.1.65 or newer, but updating ${executable} failed: ${detail}`);
707
- reportQoderVersionProbe(probe);
708
- return { ok: false, version: probe.version, executable };
709
- }
710
-
711
- // The update reported success; give the installation the bounded schedule to
712
- // start answering with the version it just installed.
713
- probe = probeQoderVersionUntil(profile, executable, (candidate) => qoderVersionMeetsMinimum(candidate.version));
714
- if (!qoderVersionMeetsMinimum(probe.version)) {
715
- error(`${profile.displayName} requires version 1.1.65 or newer, but ${executable} reports ${probe.version} after updating to latest.`);
704
+ return qoderVersionMeetsMinimum(probe.version) ? { ok: true, version: probe.version, executable } : null;
705
+ };
706
+ // Known private generations have no in-place writer in this protocol.
707
+ // External installations still coordinate with legacy writers. Never delete
708
+ // their tickets: a blocked installation can only be replaced by a new prefix.
709
+ let verified;
710
+ try {
711
+ verified = generations.contains(opts, executable) ? inspect()
712
+ : withQoderUpdateLock(executable, inspect, 2000);
713
+ } catch (err) {
714
+ log(`Shared Qoder installation is unavailable (${err.message}); selecting a separate generation.`);
715
+ }
716
+ if (verified) return verified;
717
+ let selected = generations.current(opts);
718
+ if (!selected && probe && probe.outcome !== "ok"
719
+ && !executable.startsWith(`${qoderPaths(profile).managedDir}${path.sep}`)
720
+ && !generations.contains(opts, executable)) {
721
+ error(`${profile.displayName} requires version 1.1.65 or newer; version probe failed without evidence of an old version.`);
716
722
  reportQoderVersionProbe(probe);
717
723
  return { ok: false, version: probe.version, executable };
718
724
  }
719
- log(`${profile.displayName} updated in place to ${probe.version}.`);
720
- return { ok: true, version: probe.version, executable };
725
+ if (!selected) selected = installQoderCLI(provider);
726
+ if (!selected) return { ok: false, version: probe?.version || "unknown", executable };
727
+ const finalProbe = probeQoderVersionUntil(profile, selected, (p) => qoderVersionMeetsMinimum(p.version));
728
+ return { ok: qoderVersionMeetsMinimum(finalProbe.version), version: finalProbe.version, executable: selected };
721
729
  }
722
730
 
723
731
  // A missing or unreadable record means "no history", never a bootstrap failure.
@@ -1130,7 +1138,7 @@ async function ensureQoderReady(provider, options = {}) {
1130
1138
  return false;
1131
1139
  }
1132
1140
 
1133
- const managedCopy = executable.startsWith(`${paths.managedDir}${path.sep}`);
1141
+ const managedCopy = generations.contains(generationOptions(profile), executable) || executable.startsWith(`${paths.managedDir}${path.sep}`);
1134
1142
 
1135
1143
  // B — a managed copy that cannot run at all gets refreshed once.
1136
1144
  if (verdict.state === "cli_unusable" && managedCopy && !refreshed) {
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ // Schema and directory layout are shared with pkg/agent/qoder_install.go.
7
+ const GENERATION = /^gen-[a-zA-Z0-9_-]+$/;
8
+
9
+ function readPointer(root) {
10
+ try {
11
+ const pointer = JSON.parse(fs.readFileSync(path.join(root, "current.json"), "utf8"));
12
+ return pointer.schema === 1 ? pointer : {};
13
+ } catch { return {}; }
14
+ }
15
+
16
+ function executablePath(root, generation, executable) {
17
+ if (typeof generation !== "string" || !GENERATION.test(generation)) return "";
18
+ try {
19
+ const dir = path.join(root, generation);
20
+ const stat = fs.lstatSync(dir);
21
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return "";
22
+ const bin = path.join(dir, "node_modules", ".bin", executable);
23
+ const relative = path.relative(fs.realpathSync(dir), fs.realpathSync(bin));
24
+ if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) return "";
25
+ return bin;
26
+ } catch { return ""; }
27
+ }
28
+
29
+ function current({ root, executable, verify }) {
30
+ const pointer = readPointer(root);
31
+ for (const generation of [pointer.generation, pointer.previous]) {
32
+ const bin = executablePath(root, generation, executable);
33
+ if (bin && verify(bin)) return bin;
34
+ }
35
+ return "";
36
+ }
37
+
38
+ function contains({ root, executable }, bin) {
39
+ const parts = path.relative(root, bin).split(path.sep);
40
+ return parts.length === 4 && executablePath(root, parts[0], executable) === bin;
41
+ }
42
+
43
+ function install({ root, executable, install: runInstall, verify }) {
44
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
45
+ const prefix = fs.mkdtempSync(path.join(root, "gen-"));
46
+ // Failure leaves an unselected directory. Never race a surviving npm helper
47
+ // by deleting its prefix; the next install owns a different directory.
48
+ if (!runInstall(prefix, executable)) {
49
+ throw new Error("private Qoder installation failed; previous generation retained");
50
+ }
51
+ const generation = path.basename(prefix);
52
+ const bin = executablePath(root, generation, executable);
53
+ if (!bin || !verify(bin)) {
54
+ throw new Error("private Qoder generation failed version verification; previous generation retained");
55
+ }
56
+ const previous = readPointer(root).generation;
57
+ const pointer = { schema: 1, generation };
58
+ if (typeof previous === "string" && GENERATION.test(previous)) pointer.previous = previous;
59
+ const tmp = path.join(root, `.current-${generation}`);
60
+ const fd = fs.openSync(tmp, "wx", 0o600);
61
+ try {
62
+ fs.writeFileSync(fd, JSON.stringify(pointer));
63
+ fs.fsyncSync(fd);
64
+ } finally { fs.closeSync(fd); }
65
+ try {
66
+ fs.renameSync(tmp, path.join(root, "current.json"));
67
+ } finally {
68
+ try { fs.unlinkSync(tmp); } catch {}
69
+ }
70
+ // Windows rename has platform-specific durability; the executable is always
71
+ // verified again on read, so a lost pointer after power loss triggers retry.
72
+ if (process.platform !== "win32") {
73
+ const directory = fs.openSync(root, "r");
74
+ try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
75
+ }
76
+ return bin;
77
+ }
78
+
79
+ module.exports = { current, install, contains, executablePath };
@@ -26,6 +26,7 @@ const crypto = require('crypto');
26
26
  const fs = require('fs');
27
27
  const path = require('path');
28
28
  const os = require('os');
29
+ const zlib = require('zlib');
29
30
 
30
31
  // ─── Config ──────────────────────────────────────────────────────────────────
31
32
 
@@ -49,8 +50,10 @@ function intEnv(name, fallback) {
49
50
  }
50
51
 
51
52
  // Qoder/QoderCN LLM API hosts to sniff (others are tunneled without inspection).
52
- // Only LLM endpoints are listed — non-LLM APIs (openapi.*) are excluded to avoid
53
- // unnecessary MITM overhead on high-frequency config/auth calls.
53
+ // The exact list documents the canonical API endpoints; the suffix list keeps
54
+ // sniffing working when the vendor adds or moves endpoints to another subdomain
55
+ // (e.g. *.qoder.xin), which would otherwise be blind-tunneled and silently drop
56
+ // usage. Both only ever inspect traffic locally — nothing is uploaded.
54
57
  const SNIFF_HOSTS = new Set([
55
58
  // Qoder (international)
56
59
  'api3.qoder.sh', 'api2.qoder.sh', 'api.qoder.sh', 'openapi.qoder.sh',
@@ -58,6 +61,17 @@ const SNIFF_HOSTS = new Set([
58
61
  'gateway.qoder.com.cn', 'openapi.qoder.com.cn',
59
62
  'api3.qodercn.com', 'api2.qodercn.com', 'api.qodercn.com',
60
63
  ]);
64
+ const SNIFF_HOST_SUFFIXES = ['.qoder.sh', '.qodercn.com', '.qoder.com.cn', '.qoder.xin'];
65
+
66
+ // shouldSniffHost matches case-insensitively on the exact list first and then on
67
+ // the owned-domain suffixes. A trailing root dot is ignored.
68
+ function shouldSniffHost(hostname, exactHosts = SNIFF_HOSTS, suffixes = SNIFF_HOST_SUFFIXES) {
69
+ const host = String(hostname || '').trim().toLowerCase().replace(/\.$/, '');
70
+ if (!host) return false;
71
+ const exact = typeof exactHosts.has === 'function' ? exactHosts.has(host) : exactHosts.includes(host);
72
+ if (exact) return true;
73
+ return suffixes.some(suffix => host === suffix.slice(1) || host.endsWith(suffix));
74
+ }
61
75
 
62
76
  // ─── Managed log rotation (proxy-diag.log) ───────────────────────────────────
63
77
 
@@ -148,10 +162,25 @@ function installDiagLog(logPath = DIAG_LOG_PATH) {
148
162
 
149
163
  // ─── CA Certificate (auto-generated, only for this proxy) ────────────────────
150
164
 
165
+ // readCertPair rejects missing, zero-byte and unparsable pairs so a partial
166
+ // file left by an interrupted signing run is regenerated instead of reused.
167
+ function readCertPair(certPath, keyPath) {
168
+ try {
169
+ const cert = fs.readFileSync(certPath);
170
+ const key = fs.readFileSync(keyPath);
171
+ if (cert.length === 0 || key.length === 0) return null;
172
+ new crypto.X509Certificate(cert);
173
+ return { key, cert };
174
+ } catch (_) {
175
+ return null;
176
+ }
177
+ }
178
+
151
179
  function ensureCA() {
152
180
  fs.mkdirSync(DATA_DIR, { recursive: true });
153
- if (fs.existsSync(CA_KEY_PATH) && fs.existsSync(CA_CERT_PATH)) {
154
- return { key: fs.readFileSync(CA_KEY_PATH), cert: fs.readFileSync(CA_CERT_PATH) };
181
+ const existing = readCertPair(CA_CERT_PATH, CA_KEY_PATH);
182
+ if (existing) {
183
+ return existing;
155
184
  }
156
185
 
157
186
  const { privateKey } = crypto.generateKeyPairSync('rsa', {
@@ -208,10 +237,16 @@ function getHostCert(hostname, ca) {
208
237
  const keyPath = path.join(DATA_DIR, `${hostname}-key.pem`);
209
238
 
210
239
  // Return cached cert from disk if still valid
211
- if (fs.existsSync(certPath) && fs.existsSync(keyPath) && !isCertExpired(certPath)) {
212
- const result = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
213
- hostCertCache.set(hostname, result);
214
- return result;
240
+ const onDisk = readCertPair(certPath, keyPath);
241
+ if (onDisk && !isCertExpired(certPath)) {
242
+ hostCertCache.set(hostname, onDisk);
243
+ return onDisk;
244
+ }
245
+ if (!onDisk) {
246
+ // A zero-byte or truncated pair from an interrupted run must not shadow a
247
+ // fresh signing attempt.
248
+ try { fs.unlinkSync(certPath); } catch (_) {}
249
+ try { fs.unlinkSync(keyPath); } catch (_) {}
215
250
  }
216
251
 
217
252
  const { privateKey } = crypto.generateKeyPairSync('rsa', {
@@ -221,39 +256,65 @@ function getHostCert(hostname, ca) {
221
256
  });
222
257
 
223
258
  const { execFileSync } = require('child_process');
224
- const csrPath = path.join(DATA_DIR, `${hostname}.csr`);
259
+ // Per-process temp names keep two proxies that share one data dir from
260
+ // overwriting each other's signing inputs.
261
+ const csrPath = path.join(DATA_DIR, `${hostname}.${process.pid}.csr`);
262
+ const extPath = path.join(DATA_DIR, `${hostname}.${process.pid}.ext`);
225
263
  fs.writeFileSync(keyPath, privateKey, { mode: 0o600 });
264
+ fs.writeFileSync(extPath, `[v3_ext]\nsubjectAltName=DNS:${hostname}\n`, { mode: 0o600 });
265
+
266
+ // A per-signing random serial removes the shared ca-cert.srl file that
267
+ // concurrent signings used to race on. The ext file is a real path because
268
+ // openssl cannot reopen /dev/stdin when stdin is a Node pipe.
269
+ const sign = extraArgs => execFileSync('openssl', [
270
+ 'x509', '-req', '-in', csrPath, '-CA', CA_CERT_PATH, '-CAkey', CA_KEY_PATH,
271
+ '-set_serial', `0x${crypto.randomBytes(16).toString('hex')}`,
272
+ '-out', certPath, '-days', '365', ...extraArgs,
273
+ ], { stdio: 'pipe' });
226
274
 
227
275
  try {
228
276
  execFileSync('openssl', ['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${hostname}`], { stdio: 'pipe' });
229
- execFileSync('openssl', [
230
- 'x509', '-req', '-in', csrPath, '-CA', CA_CERT_PATH, '-CAkey', CA_KEY_PATH,
231
- '-CAcreateserial', '-out', certPath, '-days', '365',
232
- '-extfile', '/dev/stdin', '-extensions', 'v3_ext',
233
- ], { input: `[v3_ext]\nsubjectAltName=DNS:${hostname}\n`, stdio: ['pipe', 'pipe', 'pipe'] });
277
+ sign(['-extfile', extPath, '-extensions', 'v3_ext']);
234
278
  } catch (_) {
235
279
  // Windows fallback: without SAN extension
236
280
  try {
237
281
  execFileSync('openssl', ['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${hostname}`], { stdio: 'pipe' });
238
- execFileSync('openssl', [
239
- 'x509', '-req', '-in', csrPath, '-CA', CA_CERT_PATH, '-CAkey', CA_KEY_PATH,
240
- '-CAcreateserial', '-out', certPath, '-days', '365',
241
- ], { stdio: 'pipe' });
282
+ sign([]);
242
283
  } catch (e2) {
243
- // Cannot generate host cert — fall back to blind tunnel (no sniffing)
244
- hostCertCache.set(hostname, null);
284
+ // Cannot generate host cert — report and fall back to a blind tunnel.
285
+ // The failure is not cached so a later connection can retry.
286
+ reportHostCertFailure(hostname, e2);
287
+ try { fs.unlinkSync(certPath); } catch (_) {}
288
+ try { fs.unlinkSync(csrPath); } catch (_) {}
289
+ try { fs.unlinkSync(extPath); } catch (_) {}
245
290
  return null;
246
291
  }
247
292
  }
248
293
 
249
- const result = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
294
+ const result = readCertPair(certPath, keyPath);
295
+ if (!result) {
296
+ reportHostCertFailure(hostname, new Error('signed certificate is unreadable'));
297
+ try { fs.unlinkSync(certPath); } catch (_) {}
298
+ try { fs.unlinkSync(csrPath); } catch (_) {}
299
+ try { fs.unlinkSync(extPath); } catch (_) {}
300
+ return null;
301
+ }
250
302
  hostCertCache.set(hostname, result);
251
303
  // Cleanup temp files
252
304
  try { fs.unlinkSync(csrPath); } catch (_) {}
253
- try { fs.unlinkSync(path.join(DATA_DIR, 'ca-cert.srl')); } catch (_) {}
305
+ try { fs.unlinkSync(extPath); } catch (_) {}
254
306
  return result;
255
307
  }
256
308
 
309
+ // reportHostCertFailure emits one bounded line per failed host so a blind
310
+ // tunnel is visible in the proxy log; it never includes credentials.
311
+ function reportHostCertFailure(hostname, err) {
312
+ const detail = String((err && err.message) || err || 'unknown error').split('\n')[0].slice(0, 200);
313
+ try {
314
+ console.error(`[token-proxy] host cert generation failed host=${hostname}: ${detail}`);
315
+ } catch (_) {}
316
+ }
317
+
257
318
  // ─── Usage extraction from Qoder SSE ────────────────────────────────────────
258
319
 
259
320
  function extractUsageFromResponse(buffer) {
@@ -410,6 +471,335 @@ function extractDispatchInfo(req) {
410
471
  return { dispatchId: null, stepId: null };
411
472
  }
412
473
 
474
+ // ─── HTTP/1.1 framing + content decoding ────────────────────────────────────
475
+
476
+ // A single SSE data line can legitimately exceed the old 64KiB cap. Keeping a
477
+ // much larger ceiling means a usage payload is never cut mid-line before the
478
+ // newline arrives.
479
+ const DEFAULT_MAX_LINE_BYTES = 8 * 1024 * 1024;
480
+
481
+ // createBodyDecoder inflates a response body according to Content-Encoding.
482
+ // Unknown encodings and decoder failures pass raw bytes through unchanged:
483
+ // usage extraction is best-effort and must never affect the tunneled traffic.
484
+ // onFlush runs once, after every decoded byte has been emitted.
485
+ function createBodyDecoder(encoding, emit, onFlush = () => {}) {
486
+ const name = String(encoding || '').trim().toLowerCase();
487
+ let stream = null;
488
+ if (name === 'gzip' || name === 'x-gzip') stream = zlib.createGunzip();
489
+ else if (name === 'deflate') stream = zlib.createInflate();
490
+ else if (name === 'br') stream = zlib.createBrotliDecompress();
491
+ if (!stream) return { active: false, write: chunk => emit(chunk), end: onFlush, cancel() {} };
492
+
493
+ let broken = false;
494
+ let finished = false;
495
+ const finish = () => {
496
+ if (finished) return;
497
+ finished = true;
498
+ onFlush();
499
+ };
500
+ stream.on('data', chunk => { if (!broken) emit(chunk); });
501
+ stream.on('error', () => { broken = true; finish(); });
502
+ stream.on('end', finish);
503
+ return {
504
+ active: true,
505
+ write(chunk) {
506
+ if (broken) { emit(chunk); return; }
507
+ try { stream.write(chunk); } catch (_) { broken = true; emit(chunk); }
508
+ },
509
+ end() {
510
+ if (broken) { finish(); return; }
511
+ try { stream.end(); } catch (_) { broken = true; finish(); }
512
+ },
513
+ cancel() { broken = true; },
514
+ };
515
+ }
516
+
517
+ function findHeadEnd(buffer) {
518
+ const crlf = buffer.indexOf('\r\n\r\n');
519
+ if (crlf >= 0) return { index: crlf, next: crlf + 4 };
520
+ const lf = buffer.indexOf('\n\n');
521
+ if (lf >= 0) return { index: lf, next: lf + 2 };
522
+ return null;
523
+ }
524
+
525
+ // createResponseExtractor de-frames the upstream byte stream of one tunnel.
526
+ // It follows HTTP/1.1 Content-Length / chunked framing so keep-alive responses
527
+ // and chunk boundaries can never inject chunk metadata into a JSON line, and it
528
+ // decodes Content-Encoding (gzip/deflate/br) before handing body bytes to
529
+ // onData. onResponseEnd fires at every body boundary so a final line without a
530
+ // trailing newline is still scanned.
531
+ function createResponseExtractor({ onData = () => {}, onResponseEnd = () => {},
532
+ maxHeadBytes = 65536 } = {}) {
533
+ let pending = Buffer.alloc(0);
534
+ // head | fixed | chunk-size | chunk-data | chunk-crlf | trailers | until-close | raw
535
+ let mode = 'head';
536
+ let remaining = 0;
537
+ let encoding = null;
538
+ let decoder = null;
539
+ let responseCount = 0;
540
+ let sawRaw = false;
541
+
542
+ function switchToRaw() {
543
+ sawRaw = true;
544
+ mode = 'raw';
545
+ if (decoder) decoder.cancel();
546
+ decoder = null;
547
+ encoding = null;
548
+ }
549
+
550
+ function openDecoder() {
551
+ decoder = createBodyDecoder(encoding, onData, onResponseEnd);
552
+ }
553
+
554
+ function emitBody(chunk) {
555
+ if (!chunk.length) return;
556
+ if (decoder) decoder.write(chunk);
557
+ else onData(chunk);
558
+ }
559
+
560
+ function finishBody() {
561
+ const active = decoder;
562
+ decoder = null;
563
+ encoding = null;
564
+ mode = 'head';
565
+ if (!active) { onResponseEnd(); return; }
566
+ // Wait for inflate to drain so the final line is scanned before the
567
+ // response boundary is reported.
568
+ active.end();
569
+ }
570
+
571
+ function takeLine() {
572
+ const newline = pending.indexOf(0x0a);
573
+ if (newline < 0) return null;
574
+ let end = newline;
575
+ if (end > 0 && pending[end - 1] === 0x0d) end--;
576
+ const value = pending.subarray(0, end).toString('latin1');
577
+ pending = pending.subarray(newline + 1);
578
+ return value;
579
+ }
580
+
581
+ function parseHead(head) {
582
+ const lines = head.split(/\r?\n/);
583
+ const statusLine = lines.shift() || '';
584
+ const statusMatch = statusLine.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
585
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
586
+ const headers = {};
587
+ for (const line of lines) {
588
+ const colon = line.indexOf(':');
589
+ if (colon <= 0) continue;
590
+ const name = line.slice(0, colon).trim().toLowerCase();
591
+ const value = line.slice(colon + 1).trim();
592
+ headers[name] = headers[name] ? `${headers[name]},${value}` : value;
593
+ }
594
+ responseCount++;
595
+ if (status === 101) {
596
+ // Protocol switch: the remainder is an opaque frame stream.
597
+ switchToRaw();
598
+ return;
599
+ }
600
+ if (status < 200 || status === 204 || status === 304) {
601
+ mode = 'head';
602
+ return;
603
+ }
604
+ encoding = headers['content-encoding'] || null;
605
+ if (/\bchunked\b/i.test(headers['transfer-encoding'] || '')) {
606
+ mode = 'chunk-size';
607
+ openDecoder();
608
+ return;
609
+ }
610
+ const lengthHeader = headers['content-length'];
611
+ if (lengthHeader !== undefined) {
612
+ remaining = parseInt(lengthHeader, 10);
613
+ if (!Number.isFinite(remaining) || remaining < 0) {
614
+ mode = 'until-close';
615
+ openDecoder();
616
+ return;
617
+ }
618
+ if (remaining === 0) {
619
+ encoding = null;
620
+ mode = 'head';
621
+ return;
622
+ }
623
+ mode = 'fixed';
624
+ openDecoder();
625
+ return;
626
+ }
627
+ mode = 'until-close';
628
+ openDecoder();
629
+ }
630
+
631
+ function process() {
632
+ for (;;) {
633
+ if (mode === 'head') {
634
+ if (!pending.length) return;
635
+ if (pending.length < 5) return;
636
+ if (pending.subarray(0, 5).toString('latin1') !== 'HTTP/') {
637
+ // Not an HTTP stream (unexpected framing): scan the bytes as text.
638
+ switchToRaw();
639
+ continue;
640
+ }
641
+ const headEnd = findHeadEnd(pending);
642
+ if (!headEnd) {
643
+ if (pending.length > maxHeadBytes) { switchToRaw(); continue; }
644
+ return;
645
+ }
646
+ const head = pending.subarray(0, headEnd.index).toString('latin1');
647
+ pending = pending.subarray(headEnd.next);
648
+ parseHead(head);
649
+ continue;
650
+ }
651
+
652
+ if (mode === 'fixed') {
653
+ if (!pending.length) return;
654
+ const take = Math.min(remaining, pending.length);
655
+ const chunk = pending.subarray(0, take);
656
+ pending = pending.subarray(take);
657
+ remaining -= take;
658
+ emitBody(chunk);
659
+ if (remaining === 0) finishBody();
660
+ continue;
661
+ }
662
+
663
+ if (mode === 'chunk-size') {
664
+ const line = takeLine();
665
+ if (line === null) return;
666
+ const size = parseInt(line.split(';')[0].trim(), 16);
667
+ if (!Number.isFinite(size) || size < 0) { switchToRaw(); continue; }
668
+ remaining = size;
669
+ mode = size === 0 ? 'trailers' : 'chunk-data';
670
+ continue;
671
+ }
672
+
673
+ if (mode === 'chunk-data') {
674
+ if (!pending.length) return;
675
+ const take = Math.min(remaining, pending.length);
676
+ const chunk = pending.subarray(0, take);
677
+ pending = pending.subarray(take);
678
+ remaining -= take;
679
+ emitBody(chunk);
680
+ if (remaining === 0) mode = 'chunk-crlf';
681
+ continue;
682
+ }
683
+
684
+ if (mode === 'chunk-crlf') {
685
+ if (pending.length < 2) {
686
+ if (pending.length === 1 && pending[0] !== 0x0d && pending[0] !== 0x0a) { switchToRaw(); continue; }
687
+ return;
688
+ }
689
+ if (pending[0] === 0x0d && pending[1] === 0x0a) pending = pending.subarray(2);
690
+ else if (pending[0] === 0x0a) pending = pending.subarray(1);
691
+ else { switchToRaw(); continue; }
692
+ mode = 'chunk-size';
693
+ continue;
694
+ }
695
+
696
+ if (mode === 'trailers') {
697
+ const line = takeLine();
698
+ if (line === null) return;
699
+ if (line.trim() === '') finishBody();
700
+ continue;
701
+ }
702
+
703
+ // until-close and raw forward everything that is left.
704
+ if (!pending.length) return;
705
+ const chunk = pending;
706
+ pending = Buffer.alloc(0);
707
+ if (mode === 'until-close') emitBody(chunk);
708
+ else onData(chunk);
709
+ return;
710
+ }
711
+ }
712
+
713
+ return {
714
+ get mode() { return mode; },
715
+ get encoding() { return encoding; },
716
+ get responseCount() { return responseCount; },
717
+ get sawRaw() { return sawRaw; },
718
+ push(chunk) {
719
+ if (!chunk || !chunk.length) return;
720
+ pending = pending.length ? Buffer.concat([pending, chunk]) : Buffer.from(chunk);
721
+ process();
722
+ },
723
+ flush() {
724
+ if (mode === 'until-close' || mode === 'raw') {
725
+ if (pending.length) {
726
+ const rest = pending;
727
+ pending = Buffer.alloc(0);
728
+ if (mode === 'until-close') emitBody(rest);
729
+ else onData(rest);
730
+ }
731
+ finishBody();
732
+ return;
733
+ }
734
+ // A truncated framed body may still carry a scannable tail.
735
+ const partial = pending.length > 0 || mode !== 'head';
736
+ if (pending.length) {
737
+ const rest = pending;
738
+ pending = Buffer.alloc(0);
739
+ if (mode === 'head') onData(rest);
740
+ else emitBody(rest);
741
+ }
742
+ if (decoder) {
743
+ finishBody();
744
+ return;
745
+ }
746
+ if (partial) onResponseEnd();
747
+ },
748
+ };
749
+ }
750
+
751
+ // createLineScanner splits a byte stream into lines without assuming that a
752
+ // chunk boundary aligns with a line boundary. Oversized lines keep their tail
753
+ // (the usage object sits at the end of an SSE line), so a single huge line can
754
+ // never push the rest of the stream out of the scan window.
755
+ function createLineScanner(onLine, { maxLineBytes = DEFAULT_MAX_LINE_BYTES } = {}) {
756
+ let parts = [];
757
+ let held = 0;
758
+
759
+ function emit(line) {
760
+ if (!line.length) return;
761
+ if (line[line.length - 1] === 0x0d) line = line.subarray(0, line.length - 1);
762
+ if (!line.length) return;
763
+ onLine(line.toString('utf8'));
764
+ }
765
+
766
+ return {
767
+ push(chunk) {
768
+ let start = 0;
769
+ while (start < chunk.length) {
770
+ const newline = chunk.indexOf(0x0a, start);
771
+ if (newline < 0) {
772
+ const rest = chunk.subarray(start);
773
+ parts.push(rest);
774
+ held += rest.length;
775
+ if (held > maxLineBytes) {
776
+ const all = Buffer.concat(parts);
777
+ parts = [all.subarray(all.length - maxLineBytes)];
778
+ held = maxLineBytes;
779
+ }
780
+ return;
781
+ }
782
+ if (parts.length) {
783
+ parts.push(chunk.subarray(start, newline));
784
+ emit(Buffer.concat(parts));
785
+ parts = [];
786
+ held = 0;
787
+ } else {
788
+ emit(chunk.subarray(start, newline));
789
+ }
790
+ start = newline + 1;
791
+ }
792
+ },
793
+ flush() {
794
+ if (!parts.length) return;
795
+ const line = Buffer.concat(parts);
796
+ parts = [];
797
+ held = 0;
798
+ emit(line);
799
+ },
800
+ };
801
+ }
802
+
413
803
  // ─── Forward Proxy (CONNECT method) ─────────────────────────────────────────
414
804
 
415
805
  // Bound connection setup separately from idle time: a peer can trickle bytes
@@ -458,7 +848,8 @@ function guardConnection(sockets, { readyEvents, connectTimeoutMs = 15000,
458
848
  }
459
849
 
460
850
  function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
461
- connectTimeoutMs = 15000, idleTimeoutMs = 120000, sniffHosts = SNIFF_HOSTS } = {}) {
851
+ connectTimeoutMs = 15000, idleTimeoutMs = 120000, sniffHosts = SNIFF_HOSTS,
852
+ sniffSuffixes = SNIFF_HOST_SUFFIXES } = {}) {
462
853
  const dbg = DEBUG ? (...args) => console.log(...args) : () => {};
463
854
 
464
855
  const server = http.createServer((req, res) => {
@@ -473,7 +864,7 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
473
864
  const ts = () => new Date().toISOString().slice(11, 23);
474
865
  const [hostname, portStr] = req.url.split(':');
475
866
  const remotePort = parseInt(portStr) || 443;
476
- const shouldSniff = sniffHosts.has(hostname);
867
+ const shouldSniff = shouldSniffHost(hostname, sniffHosts, sniffSuffixes);
477
868
  const { dispatchId, stepId } = extractDispatchInfo(req);
478
869
 
479
870
  dbg(`[${ts()}] CONNECT #${seq} ${hostname}:${remotePort} sniff=${shouldSniff} dispatch=${dispatchId || 'none'} step=${stepId || 'none'}`);
@@ -512,8 +903,11 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
512
903
  let tlsClientReady = false;
513
904
  tlsClient.on('secure', () => { tlsClientReady = true; dbg(`[${ts()}] #${seq} TLS-client handshake OK`); });
514
905
 
515
- // Connect to real upstream
516
- const tlsUpstream = tls.connect({ host: hostname, port: remotePort, servername: hostname });
906
+ // Connect to real upstream. SNI is only defined for host names, and Node
907
+ // rejects an IP-literal servername, so IP targets are connected without it.
908
+ const upstreamOptions = { host: hostname, port: remotePort };
909
+ if (!net.isIP(hostname)) upstreamOptions.servername = hostname;
910
+ const tlsUpstream = tls.connect(upstreamOptions);
517
911
 
518
912
  let tlsUpstreamReady = false;
519
913
  tlsUpstream.on('secureConnect', () => { tlsUpstreamReady = true; dbg(`[${ts()}] #${seq} TLS-upstream handshake OK → ${hostname}`); });
@@ -540,27 +934,31 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
540
934
  return text.includes('prompt_tokens') || text.includes('input_tokens');
541
935
  }
542
936
 
543
- // lineBuffer accumulates complete lines; incomplete trailing data stays for next chunk
544
- let lineRemainder = '';
545
-
546
- function processLines(lines) {
547
- // Extract ALL usage records from a batch of complete lines (not just the last one)
548
- for (const line of lines.split('\n')) {
549
- const trimmed = line.trim();
550
- if (!trimmed) continue;
551
- if (!hasUsageSignal(trimmed)) continue;
552
- const buf = Buffer.from(trimmed, 'utf8');
553
- const usage = extractUsageFromResponse(buf);
554
- if (usage) {
555
- usageCount++;
556
- if (dispatchId) usage.dispatch_id = dispatchId;
557
- if (stepId) usage.step_id = stepId;
558
- appendUsage(usage);
559
- console.log(`[${ts()}] #${seq} ✓ USAGE CAPTURED (#${usageCount}) credits=${usage.credits} model=${usage.model} in=${usage.input_tokens} out=${usage.output_tokens}`);
560
- }
561
- }
937
+ let lineCount = 0;
938
+
939
+ function processLine(line) {
940
+ const trimmed = line.trim();
941
+ if (!trimmed) return;
942
+ lineCount++;
943
+ if (!hasUsageSignal(trimmed)) return;
944
+ const usage = extractUsageFromResponse(Buffer.from(trimmed, 'utf8'));
945
+ if (!usage) return;
946
+ usageCount++;
947
+ if (dispatchId) usage.dispatch_id = dispatchId;
948
+ if (stepId) usage.step_id = stepId;
949
+ appendUsage(usage);
950
+ console.log(`[${ts()}] #${seq} ✓ USAGE CAPTURED (#${usageCount}) credits=${usage.credits} model=${usage.model} in=${usage.input_tokens} out=${usage.output_tokens}`);
562
951
  }
563
952
 
953
+ // The extractor removes HTTP framing and content-encoding before the
954
+ // scanner splits lines, so a compressed, chunked or keep-alive response
955
+ // still yields intact JSON lines.
956
+ const scanner = createLineScanner(processLine);
957
+ const extractor = createResponseExtractor({
958
+ onData: chunk => scanner.push(chunk),
959
+ onResponseEnd: () => scanner.flush(),
960
+ });
961
+
564
962
  tlsUpstream.on('data', (chunk) => {
565
963
  totalBytes += chunk.length;
566
964
  if (rawFd !== null) {
@@ -573,15 +971,7 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
573
971
  }
574
972
  }
575
973
  try {
576
- const text = lineRemainder + chunk.toString('utf8');
577
- const lastNL = text.lastIndexOf('\n');
578
- if (lastNL === -1) {
579
- lineRemainder = text.length > 65536 ? text.slice(-65536) : text;
580
- return;
581
- }
582
- const complete = text.slice(0, lastNL);
583
- lineRemainder = text.slice(lastNL + 1);
584
- processLines(complete);
974
+ extractor.push(chunk);
585
975
  } catch (_) {}
586
976
  });
587
977
 
@@ -590,12 +980,14 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
590
980
  tlsClient.pipe(tlsUpstream);
591
981
 
592
982
  const cleanup = () => {
593
- // Final extraction from any remaining data (lineRemainder may hold last line without trailing \n)
983
+ // Drain framing state, then scan any trailing data that lacked a newline.
984
+ try {
985
+ extractor.flush();
986
+ } catch (_) {}
594
987
  try {
595
- if (lineRemainder) processLines(lineRemainder);
988
+ scanner.flush();
596
989
  } catch (_) {}
597
- lineRemainder = '';
598
- dbg(`[${ts()}] #${seq} CLOSED ${hostname} bytes=${totalBytes} usages=${usageCount} tlsClient=${tlsClientReady} tlsUpstream=${tlsUpstreamReady}`);
990
+ dbg(`[${ts()}] #${seq} CLOSED ${hostname} bytes=${totalBytes} usages=${usageCount} lines=${lineCount} responses=${extractor.responseCount} mode=${extractor.mode} raw=${extractor.sawRaw} encoding=${extractor.encoding || 'identity'} tlsClient=${tlsClientReady} tlsUpstream=${tlsUpstreamReady}`);
599
991
  if (rawFd !== null) { try { fs.closeSync(rawFd); } catch (_) {} rawFd = null; }
600
992
  };
601
993
 
@@ -626,6 +1018,7 @@ function startProxy(port) {
626
1018
  console.log(`[token-proxy] CA cert: ${CA_CERT_PATH}`);
627
1019
  console.log(`[token-proxy] Usage log: ${USAGE_LOG_PATH}`);
628
1020
  console.log(`[token-proxy] Sniffing hosts: ${[...SNIFF_HOSTS].join(', ')}`);
1021
+ console.log(`[token-proxy] Sniffing suffixes: ${SNIFF_HOST_SUFFIXES.join(', ')}`);
629
1022
  if (diagWriter) console.log(`[token-proxy] Diag log: ${diagWriter.filePath}`);
630
1023
  });
631
1024
 
@@ -730,4 +1123,7 @@ if (require.main === module) {
730
1123
  }
731
1124
 
732
1125
  module.exports = { extractUsageFromResponse, extractDispatchInfo, buildUsageRecord,
733
- guardConnection, createProxyServer, createRotatingWriter, rawLogFileName, rawSliceFor, installDiagLog };
1126
+ guardConnection, createProxyServer, createRotatingWriter, rawLogFileName, rawSliceFor, installDiagLog,
1127
+ shouldSniffHost, createBodyDecoder, createResponseExtractor, createLineScanner,
1128
+ ensureCA, getHostCert,
1129
+ SNIFF_HOSTS, SNIFF_HOST_SUFFIXES, DEFAULT_MAX_LINE_BYTES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autowonder",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "AutoWonder local runtime — execute AI agent dispatch packages on your machine",
5
5
  "bin": {
6
6
  "autowonder": "bin/cli.js"