autowonder 0.3.22 → 0.3.24
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 +12 -10
- package/bin/token-proxy.js +455 -59
- package/package.json +1 -1
- package/vendor/autowonder-daemon-darwin-amd64 +0 -0
- package/vendor/autowonder-daemon-darwin-arm64 +0 -0
- package/vendor/autowonder-daemon-linux-amd64 +0 -0
- package/vendor/autowonder-daemon-linux-arm64 +0 -0
- package/vendor/autowonder-daemon-win32-amd64.exe +0 -0
- package/vendor/autowonder-daemon-win32-arm64.exe +0 -0
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.
|
|
9
|
+
npx -y autowonder@0.3.24 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.
|
|
12
|
+
npx -y autowonder@0.3.24 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 登录状态。
|
|
@@ -119,6 +119,8 @@ npx -y autowonder@latest connect ...
|
|
|
119
119
|
|
|
120
120
|
同版本空闲时用新参数 exec;切换到更高稳定版本要求二进制安装在 `~/.autowonder/bin/autowonder-daemon`,复用 npm tarball SHA-512、包版本、平台二进制和候选自检,再原子切换。失败保留原程序并在支持的故障路径回滚,不支持在线降级。源码构建仅支持保持当前版本的参数应用,Windows 不声明在线应用能力。旧客户端需要先本地安装一次支持版本。
|
|
121
121
|
|
|
122
|
+
启动命令可携带可选的 `restartMode`,取值为 `WHEN_IDLE` 或 `FORCE`。未提供时仅在本机完全空闲时应用,繁忙时拒绝并回执失败;`WHEN_IDLE` 会立即关闭接单入口,等待运行中任务与会话结束后再重启,等待期间每 30 秒以 `EXECUTOR_RESTART_RESULT` 的 `WAITING_IDLE` 状态回执;`FORCE` 同样立即关闭接单入口,但不等运行中任务,仍要求结果 outbox 可读后立即重启。非法取值会在产生任何重启副作用前被拒绝。Linux/macOS 执行器在心跳中通过 `EXECUTOR_RESTART_POLICY_V1` 声明支持该策略。
|
|
123
|
+
|
|
122
124
|
工作目录切换前必须没有运行/恢复任务,且原目录的结果 outbox 已全部确认;默认本地队列仍有待执行或已领取任务时也会拒绝切换。目标目录必须可写;历史任务文件保留在原目录,不自动迁移或删除。未指定目录和名称时在线应用沿用当前值,仅更新其他参数不会移动本地队列。
|
|
123
125
|
|
|
124
126
|
候选版本缓存在 `~/.autowonder/updates/`;当前二进制和单个回滚副本位于 `~/.autowonder/bin/`。`GET /health` 会返回 `daemonVersion`、`updateState`、`updateTarget` 和 `updateError`。
|
|
@@ -127,7 +129,7 @@ daemon 建立连接时和每次心跳定时检查时发出的 `HEARTBEAT` 帧都
|
|
|
127
129
|
|
|
128
130
|
同一批 `HEARTBEAT` 帧还带上 `model` 字段,取值为 daemon 当前生效的默认模型(`--model` 经 `AUTOWONDER_MODEL` 注入的 `cfg.DefaultModel`,即派单未指定模型时实际使用的取值);该值为空时字段不出现在帧里。服务端据此在执行器列表展示每台执行机当前使用的底层模型,与上面的 `version` 上报同一条通路。对执行机用户同样无新增参数或环境变量。
|
|
129
131
|
|
|
130
|
-
同一批 `HEARTBEAT` 帧还带上进程级固定的 `startedAt`(重连不重置)与继承自 re-exec 的 `restartRequestId`,并在能力位里声明 `EXECUTOR_RESTART_V1`(Windows
|
|
132
|
+
同一批 `HEARTBEAT` 帧还带上进程级固定的 `startedAt`(重连不重置)与继承自 re-exec 的 `restartRequestId`,并在能力位里声明 `EXECUTOR_RESTART_V1` 与 `EXECUTOR_RESTART_POLICY_V1`(Windows 均不声明);接入自动更新的 daemon 额外声明 `EXECUTOR_UPDATE_RESTART_V1`。服务端可据此下发 `EXECUTOR_RESTART`(带 `executorId`、`requestId`、ISO UTC `issuedAt` 与布尔 `update`)。本机只接受 `executorId` 与自身一致、未过期且未处理过的请求,不接收任何远端 shell 命令、可执行路径或下载地址,并以 `EXECUTOR_RESTART_RESULT`(`UPDATING` / `WAITING_IDLE` / `RESTARTING` / `FAILED`)回执,真正是否成功以下一次心跳为准。重启走既有的接单闸门:有运行中任务或结果 outbox 未确认可读时不重启,re-exec 失败会重新开放接单;`update=true` 复用既有的 registry、完整性校验、候选 self-check、安装事务与回滚,仅标准安装的正式版支持远端拉取新版本,已是最新版的请求仍会重启,且该操作不应用平台上保存的启动配置改动。对执行机用户同样无新增参数或环境变量。
|
|
131
133
|
|
|
132
134
|
标准安装还支持服务端把整批执行机升级到指定的**已发布版本**(不再依赖各台机器安装时选定的版本),能力位为 `EXECUTOR_UPDATE_V1`。服务端下发 `EXECUTOR_UPGRADE`(带 `executorId`、`requestId`、ISO UTC `issuedAt` 与 `targetVersion`),本机只接受 `executorId` 与自身一致、`requestId` 非空且不超过 80 字符、`issuedAt` 在前后 1 分钟以内、且 `targetVersion` 为三段纯数字正式版的请求。升级按 `accepted` → `downloading` → `draining` → `applying` → `success` 逐阶段回执 `EXECUTOR_UPGRADE_RESULT`(含 `requestId`、`phase` 与该进程启动时的 `currentVersion`;新版本以下一个进程的首次心跳为准),失败则以 `failed` 帧带上错误原因——目标版本不受支持、请求过期、本机构建不支持远端升级等都会显式回执而不是静默丢弃。registry 按请求的版本号解析元数据并要求返回值与请求版本严格一致;请求版本低于当前版本时拒绝执行(服务端升级永不回滚整批执行机),已缓存但版本不符的候选不会被采用。`requestId` 会跨 re-exec 传递,服务端重放的同一请求不会触发第二次升级;同一 `requestId` 同时只允许一次尝试在飞,失败后可重试。升级复用既有的接单闸门、完整性校验、候选 self-check、安装事务与回滚。对执行机用户无新增参数或环境变量。
|
|
133
135
|
|
|
@@ -175,7 +177,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
|
|
|
175
177
|
也可以直接提交到本地 API:
|
|
176
178
|
|
|
177
179
|
```bash
|
|
178
|
-
npx -y autowonder@0.3.
|
|
180
|
+
npx -y autowonder@0.3.24 dispatch ./assignment.json
|
|
179
181
|
```
|
|
180
182
|
|
|
181
183
|
## 上传工单附件
|
|
@@ -183,7 +185,7 @@ npx -y autowonder@0.3.22 dispatch ./assignment.json
|
|
|
183
185
|
把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
|
|
184
186
|
|
|
185
187
|
```bash
|
|
186
|
-
npx -y autowonder@0.3.
|
|
188
|
+
npx -y autowonder@0.3.24 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
187
189
|
```
|
|
188
190
|
|
|
189
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)。
|
|
@@ -193,7 +195,7 @@ npx -y autowonder@0.3.22 workitem upload --server-url <autowonder-server-url> --
|
|
|
193
195
|
把工单上已有的需求/设计文档下载到本地目录,再用本地文件工具阅读,避免把大文件或二进制附件搬进会话上下文:
|
|
194
196
|
|
|
195
197
|
```bash
|
|
196
|
-
npx -y autowonder@0.3.
|
|
198
|
+
npx -y autowonder@0.3.24 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
|
|
197
199
|
```
|
|
198
200
|
|
|
199
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)。
|
|
@@ -203,7 +205,7 @@ npx -y autowonder@0.3.22 workitem download --server-url <autowonder-server-url>
|
|
|
203
205
|
把需求/设计文档上传到 7x24 定时任务,用法与工单附件一致(同一套上传令牌与预检规则):
|
|
204
206
|
|
|
205
207
|
```bash
|
|
206
|
-
npx -y autowonder@0.3.
|
|
208
|
+
npx -y autowonder@0.3.24 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
207
209
|
```
|
|
208
210
|
|
|
209
211
|
上传令牌同样通过 `autowonder.workitem_cli_upload_token` 签发(令牌为用户级,对当前可修改的任意定时任务生效),经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 传入;退出码约定与 `workitem upload` 相同。
|
|
@@ -211,8 +213,8 @@ npx -y autowonder@0.3.22 scheduled-task upload --server-url <autowonder-server-u
|
|
|
211
213
|
## 管理 daemon
|
|
212
214
|
|
|
213
215
|
```bash
|
|
214
|
-
npx -y autowonder@0.3.
|
|
215
|
-
npx -y autowonder@0.3.
|
|
216
|
+
npx -y autowonder@0.3.24 status
|
|
217
|
+
npx -y autowonder@0.3.24 stop
|
|
216
218
|
```
|
|
217
219
|
|
|
218
220
|
默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
|
|
@@ -235,7 +237,7 @@ daemon 内置独立于工作区 GC 的保留清扫:默认每小时运行一次
|
|
|
235
237
|
## 调试
|
|
236
238
|
|
|
237
239
|
```bash
|
|
238
|
-
npx -y autowonder@0.3.
|
|
240
|
+
npx -y autowonder@0.3.24 connect ... --debug
|
|
239
241
|
```
|
|
240
242
|
|
|
241
243
|
`--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
|
package/bin/token-proxy.js
CHANGED
|
@@ -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
|
-
//
|
|
53
|
-
//
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
hostCertCache.set(hostname,
|
|
214
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
244
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
983
|
+
// Drain framing state, then scan any trailing data that lacked a newline.
|
|
984
|
+
try {
|
|
985
|
+
extractor.flush();
|
|
986
|
+
} catch (_) {}
|
|
594
987
|
try {
|
|
595
|
-
|
|
988
|
+
scanner.flush();
|
|
596
989
|
} catch (_) {}
|
|
597
|
-
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|