autowonder 0.3.13 → 0.3.3001
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 +11 -25
- package/bin/qoder-bootstrap.js +58 -13
- package/bin/token-proxy.js +60 -102
- 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.3001 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.3001 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,7 +27,7 @@ runtime 启动时始终只拉取一次 Platform 环境变量快照,即使已
|
|
|
27
27
|
|
|
28
28
|
出于安全考虑,命令行 token 可能出现在 shell 历史和操作系统进程列表中,建议优先使用环境变量或 Platform 绑定。
|
|
29
29
|
|
|
30
|
-
`connect` 和 `start` 都要求 Qoder CLI / Qoder CLI CN 版本至少为 `1.1.
|
|
30
|
+
`connect` 和 `start` 都要求 Qoder CLI / Qoder CLI CN 版本至少为 `1.1.50`。检测到旧版或无法解析的版本时,runtime 会对当前优先选中的用户 CLI 执行内置 `update`,原位升级到最新版并再次强校验;升级失败或升级后仍不满足最低版本时拒绝启动 daemon。所有 Qoder 系列子进程(普通任务、conversation、warm 会话、恢复回退、命令与模型探测)都会强制注入固定的 `QODER_INTEGRATION_ID`,调用方环境不能覆盖该流量归因标识。
|
|
31
31
|
|
|
32
32
|
`qodercn` 是国内版 Qoder CLI。runtime 会在缺少 `qodercn` 时自动把 `@qodercn-ai/qoderclicn` 安装到 `~/.autowonder/qoder-cn-cli`,并在尚未登录时打开浏览器登录。它使用自己的配置根(`QODERCN_CONFIG_DIR`,默认 `~/.qoder-cn`),与国际版 `qoder` 不共享可执行文件、凭证和会话。可用 `AUTOWONDER_QODERCN_CLI` 指定自定义可执行文件路径。
|
|
33
33
|
|
|
@@ -35,13 +35,9 @@ Qoder 的 Context Window 只能使用 `1000000`、`400000` 或 `260000`,Reason
|
|
|
35
35
|
|
|
36
36
|
daemon 会响应服务端的 `QODER_MODEL_CATALOG_REQUEST` 帧,用当前 provider 对应的本机 Qoder CLI 发现可用模型目录,只回传 `{id, name}`,让执行器页面展示这台机器真实可选的模型。该请求仅在 `executorId` 与 `provider` 都与本机一致时处理,发现超时 20 秒,并发请求合并为一次发现,`qoder` 与 `qodercn` 的发现互相隔离、不共享配置根。
|
|
37
37
|
|
|
38
|
-
daemon 还在协议能力位里声明 `TASK_MODEL_SELECTION_V1`:服务端可在派单帧中附带任务级模型选择(`modelSelection`,含目标模型、条目计数与阈值、策略版本、原因码)。runtime 只对本次派单应用该模型(覆盖派单原有的 model 取值,不修改执行器保存的默认配置),并在开始执行时把 `dispatchId`、选中模型与原因写入 daemon 日志,便于回溯某个任务因何被升级到另一个模型。该能力对执行机用户无新增参数或环境变量。
|
|
39
|
-
|
|
40
|
-
daemon 建立执行器接入连接时会在 WebSocket URL 上附带本机主机名(取不到时不带该参数,服务端按未上报处理),服务端据此在执行器列表区分同一数字员工在多台机器上的执行器。对执行机用户无新增参数或环境变量。
|
|
41
|
-
|
|
42
38
|
daemon 还会响应服务端的 `CONVERSATION_COMMANDS_PROBE` 帧,用独立会话抓取该会话工作目录下可用的斜杠命令,回传 `CONVERSATION_COMMANDS_RESULT` 帧,让会话面板在用户输入 `/` 时列出真实可用的命令。该探测与回合执行完全隔离:任何失败只产生 `FAILED` / `TIMEOUT` 帧,不影响进行中的会话;provider 后端不支持抓取时同样降级为 `FAILED` 帧。帧里的 `executorId` 与本机不一致或非正数时直接忽略,避免在别的执行器工作区里建目录或启动 CLI。daemon 侧给整个探测 25 秒硬上限,刻意长于 agent 侧的 20 秒预算,使 agent 自己的超时先触发并可被归类。
|
|
43
39
|
|
|
44
|
-
给 `connect` 追加 `--token-aware-enable` 可开启 qoder / qodercn 的 token 用量采集:CLI 会以 sidecar 方式启动本地 HTTPS 转发代理(`bin/token-proxy.js`),从 Qoder API 的 SSE 响应流中提取 usage 数据并按 dispatch ID
|
|
40
|
+
给 `connect` 追加 `--token-aware-enable` 可开启 qoder / qodercn 的 token 用量采集:CLI 会以 sidecar 方式启动本地 HTTPS 转发代理(`bin/token-proxy.js`),从 Qoder API 的 SSE 响应流中提取 usage 数据并按 dispatch ID 归属上报。该功能旁路设计、不阻塞任务执行,代理启动失败时仅告警并继续。代理端口可用 `TOKEN_PROXY_PORT` 环境变量调整(默认 19876);CA 证书位于 `~/.autowonder/token-tap/ca-cert.pem`。daemon 退出时 sidecar 一并停止。
|
|
45
41
|
|
|
46
42
|
`connect` 支持 `--workspace-root <path>` 指定 dispatch 仓库与任务工作区所在目录(默认 `~/autowonder_workspaces`)。该参数会转发给 daemon,等价于设置 `AUTOWONDER_WORKSPACE_ROOT` 环境变量,适合把工作区放在非默认路径的执行机上(如 Windows 的 `D:\autowonder`)。
|
|
47
43
|
|
|
@@ -83,18 +79,8 @@ daemon 默认启用本地工作区 GC:启动 30 秒后开始检查,之后每
|
|
|
83
79
|
|
|
84
80
|
`~/autowonder_workspaces`(可由 `--workspace-root` 或 `AUTOWONDER_WORKSPACE_ROOT` 修改)保存每个任务独立的工作文件和 checkout/worktree;`~/.autowonder/git-object-cache/v1` 保存可供多个任务复用 Git 历史对象的本地 bare mirror。每个任务仍会得到所请求准确 commit 的独立工作区。已缓存的固定 commit 可以不查询远端;允许联网时,`HEAD`、branch 或 tag 等可移动 ref 会在每次 materialization 时向远端解析为准确 commit,仅在 mirror 缺少该对象时 fetch。缓存优化失败时,只有其临时目录已经完成可验证的干净回滚才会回退到原有的远端完整 clone 流程;若无法证明干净回滚,则会保留现场并返回错误,不进入回退。任何路径都不会发布不完整的目标目录;已经存在的任务仓库只做校验,不会被 fetch、reset、clean、checkout 或覆盖。本版本不会自动淘汰 Git 对象缓存,工作区 GC 也不会删除该缓存。
|
|
85
81
|
|
|
86
|
-
任务包 `repos.json` 可为仓库声明继承来源 revision 的回退列表(`sourceRevisionFallbackRefs`):当派单要求的继承 revision 已无法解析为 commit(例如上游分支被删除或强推)时,runtime 会按该列表依次回退物化,命中后以回退 commit 作为该仓库本次使用的来源继续执行,并发出 `repo_source_revision_abandoned` 事件(含仓库、回退 commit 与原因)。只有「ref 无法解析」会触发回退——传输、权限、工作区归属与策略类失败不触发;声明了固定 base commit 的仓库不使用回退。该能力对执行机用户无新增参数或环境变量。
|
|
87
|
-
|
|
88
82
|
任务工作区里的 Git 策略(是否允许提交/推送与工作区边界)由 runtime 安装的自包含钩子强制执行:默认策略不依赖 runtime 二进制或任务配置继续存在,因此历史任务残留不会让普通的 `git commit` / `git push` 失败,策略禁止时分别以退出码 `73` / `74` 拒绝提交与推送,提交还要求 HEAD 位于具名分支(detached 时以 `77` 拒绝)。安装是幂等的,只有确实声明了打包触发的仓库才会链式调用 runtime。daemon 启动时会在 15 秒预算内把本执行器历史 capsule 中可核对的旧版包装脚本迁移为这种自包含形式;模板、配置或元数据不完整的历史一律保留现场,不做全机扫描,也不恢复用户原有的 `hooksPath`。
|
|
89
83
|
|
|
90
|
-
## 中断派单恢复
|
|
91
|
-
|
|
92
|
-
daemon 重启或崩溃后重新接管未完成的派单时,runtime 不会直接把工作区交给恢复执行:先按 capsule 的尝试目录识别并清理上一进程遗留的 provider 进程组,确认没有归属进程组残留后才进入执行;清理结果不确定时保持该派单的恢复归属并按退避重试,不会把工作区让给服务端的替代任务,也不会在遗留进程仍可能写入时确认取消。等待服务端认领期间不占用执行槽位。
|
|
93
|
-
|
|
94
|
-
恢复时只有存在与步骤 attempt 匹配的持久化完成回执(`state/step-uploads/<step>/attempt-N.json`,即该步骤已发布并被远端 ref 代表)才会复用已完成的步骤;只存在于本地进度快照里的「已完成」不再被采信,无法证明的步骤会回退为待执行并重新运行,同时发出 `recovery_unproven_step_completions` 事件。恢复执行与首次派发一样上报步骤与运行事件,保留的完成步骤会显式发出 `step_reused`,时间线不需要客户端从游标推断。
|
|
95
|
-
|
|
96
|
-
恢复执行前会把工作目录规范化为绝对路径并解析符号链接(Qoder 会话按物理目录索引),因此通过符号链接别名派发的任务在重启后仍能找到并续接原会话。
|
|
97
|
-
|
|
98
84
|
## 自动更新
|
|
99
85
|
|
|
100
86
|
安装首个支持自动更新的正式版本后,daemon 会直接查询 npm 的 `autowonder/latest`。首次检查会随机分散在启动后的 30~90 秒,之后约每 5 分钟检查一次并加入随机抖动;npm 临时错误会指数退避,HTTP 429/503 的 `Retry-After` 会被遵循。发现更高的稳定版本时,它会在后台下载 npm tarball,校验 SHA-512 SRI、包版本和平台二进制;只有在没有运行/恢复任务且结果 outbox 已收到服务端确认时,才会暂停接单、原子切换二进制并自动重启。启动失败会恢复上一个二进制。
|
|
@@ -164,7 +150,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
|
|
|
164
150
|
也可以直接提交到本地 API:
|
|
165
151
|
|
|
166
152
|
```bash
|
|
167
|
-
npx -y autowonder@0.3.
|
|
153
|
+
npx -y autowonder@0.3.3001 dispatch ./assignment.json
|
|
168
154
|
```
|
|
169
155
|
|
|
170
156
|
## 上传工单附件
|
|
@@ -172,7 +158,7 @@ npx -y autowonder@0.3.13 dispatch ./assignment.json
|
|
|
172
158
|
把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
|
|
173
159
|
|
|
174
160
|
```bash
|
|
175
|
-
npx -y autowonder@0.3.
|
|
161
|
+
npx -y autowonder@0.3.3001 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
176
162
|
```
|
|
177
163
|
|
|
178
164
|
上传令牌通过 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)。
|
|
@@ -182,7 +168,7 @@ npx -y autowonder@0.3.13 workitem upload --server-url <autowonder-server-url> --
|
|
|
182
168
|
把工单上已有的需求/设计文档下载到本地目录,再用本地文件工具阅读,避免把大文件或二进制附件搬进会话上下文:
|
|
183
169
|
|
|
184
170
|
```bash
|
|
185
|
-
npx -y autowonder@0.3.
|
|
171
|
+
npx -y autowonder@0.3.3001 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
|
|
186
172
|
```
|
|
187
173
|
|
|
188
174
|
下载令牌通过 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)。
|
|
@@ -192,7 +178,7 @@ npx -y autowonder@0.3.13 workitem download --server-url <autowonder-server-url>
|
|
|
192
178
|
把需求/设计文档上传到 7x24 定时任务,用法与工单附件一致(同一套上传令牌与预检规则):
|
|
193
179
|
|
|
194
180
|
```bash
|
|
195
|
-
npx -y autowonder@0.3.
|
|
181
|
+
npx -y autowonder@0.3.3001 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
196
182
|
```
|
|
197
183
|
|
|
198
184
|
上传令牌同样通过 `autowonder.workitem_cli_upload_token` 签发(令牌为用户级,对当前可修改的任意定时任务生效),经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 传入;退出码约定与 `workitem upload` 相同。
|
|
@@ -200,8 +186,8 @@ npx -y autowonder@0.3.13 scheduled-task upload --server-url <autowonder-server-u
|
|
|
200
186
|
## 管理 daemon
|
|
201
187
|
|
|
202
188
|
```bash
|
|
203
|
-
npx -y autowonder@0.3.
|
|
204
|
-
npx -y autowonder@0.3.
|
|
189
|
+
npx -y autowonder@0.3.3001 status
|
|
190
|
+
npx -y autowonder@0.3.3001 stop
|
|
205
191
|
```
|
|
206
192
|
|
|
207
193
|
默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
|
|
@@ -209,7 +195,7 @@ npx -y autowonder@0.3.13 stop
|
|
|
209
195
|
## 调试
|
|
210
196
|
|
|
211
197
|
```bash
|
|
212
|
-
npx -y autowonder@0.3.
|
|
198
|
+
npx -y autowonder@0.3.3001 connect ... --debug
|
|
213
199
|
```
|
|
214
200
|
|
|
215
201
|
`--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
|
package/bin/qoder-bootstrap.js
CHANGED
|
@@ -15,7 +15,7 @@ const LEGACY_QODER_HOST_SERVICE_NAME_ENV = "QODER_HOST_SERVICE_NAME";
|
|
|
15
15
|
// Qoder. DO NOT CHANGE this UUID after publication: changing it would split
|
|
16
16
|
// historical and future attribution. It is not configurable or regenerated.
|
|
17
17
|
const QODER_INTEGRATION_ID = "5b5d9168-09e0-4b8a-92f5-2ee7cd817fed";
|
|
18
|
-
const QODER_MINIMUM_VERSION = [1, 1,
|
|
18
|
+
const QODER_MINIMUM_VERSION = [1, 1, 50];
|
|
19
19
|
const QODER_LOCK_MAX_PID = 2147483647;
|
|
20
20
|
const QODER_VERSION_LINE_RE = /^(?:(?:qodercli|qodercn|qoder(?: cli(?: cn)?)?)\s+)?v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\s+\([^)]*\))?$/i;
|
|
21
21
|
const PROCESS_TREE_RUNNER = path.join(__dirname, "process-tree-runner.js");
|
|
@@ -340,6 +340,7 @@ function installQoderCLIUnlocked(provider) {
|
|
|
340
340
|
const MACHINE_ID_REL_PATH = path.join(".auth", "machine_id");
|
|
341
341
|
const CREDENTIAL_REL_PATH = path.join(".auth", "user");
|
|
342
342
|
|
|
343
|
+
// #55554: retry diagnostics are used only by the Windows startup path.
|
|
343
344
|
// Both tree runners report a timeout as exit code 124, so a bound probe stays
|
|
344
345
|
// distinguishable from a CLI that really failed to start.
|
|
345
346
|
const QODER_PROBE_TIMEOUT_STATUS = 124;
|
|
@@ -406,10 +407,6 @@ function probeQoderBinaryVersion(executable, options = {}) {
|
|
|
406
407
|
return probe;
|
|
407
408
|
}
|
|
408
409
|
|
|
409
|
-
function qoderBinaryVersion(executable, options = {}) {
|
|
410
|
-
return probeQoderBinaryVersion(executable, options).version;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
410
|
// "missing" is a definitive filesystem answer. Every other non-ok outcome can
|
|
414
411
|
// be a one-off glitch (first run, a short-lived file lock, a slow antivirus),
|
|
415
412
|
// which is exactly the field failure this retry exists for.
|
|
@@ -472,6 +469,25 @@ function probeQoderVersionUntil(profile, executable, accept, initialProbe) {
|
|
|
472
469
|
return probe;
|
|
473
470
|
}
|
|
474
471
|
|
|
472
|
+
function qoderBinaryVersion(executable, options = {}) {
|
|
473
|
+
if ((path.isAbsolute(executable) || executable.includes(path.sep)) && !fs.existsSync(executable)) return "missing";
|
|
474
|
+
const result = spawnQoderSync(executable, ["--version"], {
|
|
475
|
+
encoding: "utf8",
|
|
476
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
477
|
+
timeout: options.timeout || 20000,
|
|
478
|
+
treeTimeout: true,
|
|
479
|
+
});
|
|
480
|
+
// "missing" means the file itself is absent; "unknown" means the file is
|
|
481
|
+
// there but produced no version, so the two failure classes never collapse
|
|
482
|
+
// into one misleading answer.
|
|
483
|
+
if (result.error && result.error.code === "ENOENT") return "missing";
|
|
484
|
+
if (result.status !== 0) return "unknown";
|
|
485
|
+
// Accept only an unambiguous Qoder version line. Runtime warnings and update
|
|
486
|
+
// banners often contain unrelated/newer semvers and must never satisfy the
|
|
487
|
+
// minimum gate for an old executable.
|
|
488
|
+
return qoderVersionLine(`${result.stdout || ""}\n${result.stderr || ""}`) || "unknown";
|
|
489
|
+
}
|
|
490
|
+
|
|
475
491
|
function qoderVersionLine(output) {
|
|
476
492
|
const matches = String(output || "").split(/\r?\n/).map((line) => line.trim()).filter((line) =>
|
|
477
493
|
QODER_VERSION_LINE_RE.test(line));
|
|
@@ -487,7 +503,7 @@ function qoderVersionMeetsMinimum(output) {
|
|
|
487
503
|
if (actual[i] > QODER_MINIMUM_VERSION[i]) return true;
|
|
488
504
|
if (actual[i] < QODER_MINIMUM_VERSION[i]) return false;
|
|
489
505
|
}
|
|
490
|
-
// A prerelease of 1.1.
|
|
506
|
+
// A prerelease of 1.1.50 is lower than the released minimum.
|
|
491
507
|
return !match[4];
|
|
492
508
|
}
|
|
493
509
|
|
|
@@ -680,7 +696,7 @@ function ensureMinimumQoderVersion(profile, provider, executable) {
|
|
|
680
696
|
return result || { ok: false, version: "unknown", executable };
|
|
681
697
|
}
|
|
682
698
|
|
|
683
|
-
function
|
|
699
|
+
function ensureMinimumQoderVersionWindowsLocked(profile, provider, executable) {
|
|
684
700
|
let probe = probeQoderBinaryVersion(executable);
|
|
685
701
|
if (isTransientQoderVersionProbe(probe)) {
|
|
686
702
|
// An up-to-date CLI answers nothing far more often than it is really too
|
|
@@ -690,7 +706,7 @@ function ensureMinimumQoderVersionLocked(profile, provider, executable) {
|
|
|
690
706
|
}
|
|
691
707
|
if (qoderVersionMeetsMinimum(probe.version)) return { ok: true, version: probe.version };
|
|
692
708
|
|
|
693
|
-
log(`${profile.displayName} ${probe.version} is older than required version 1.1.
|
|
709
|
+
log(`${profile.displayName} ${probe.version} is older than required version 1.1.50 (or could not be parsed). Updating the current installation to latest...`);
|
|
694
710
|
const update = spawnQoderSync(executable, ["update"], { stdio: "inherit", timeout: 5 * 60 * 1000, treeTimeout: true });
|
|
695
711
|
if (update.status !== 0) {
|
|
696
712
|
const managedDir = qoderPaths(profile).managedDir;
|
|
@@ -703,7 +719,7 @@ function ensureMinimumQoderVersionLocked(profile, provider, executable) {
|
|
|
703
719
|
}
|
|
704
720
|
}
|
|
705
721
|
const detail = update.error?.message || (update.signal ? `signal ${update.signal}` : `exit status ${update.status}`);
|
|
706
|
-
error(`${profile.displayName} requires version 1.1.
|
|
722
|
+
error(`${profile.displayName} requires version 1.1.50 or newer, but updating ${executable} failed: ${detail}`);
|
|
707
723
|
reportQoderVersionProbe(probe);
|
|
708
724
|
return { ok: false, version: probe.version, executable };
|
|
709
725
|
}
|
|
@@ -712,7 +728,7 @@ function ensureMinimumQoderVersionLocked(profile, provider, executable) {
|
|
|
712
728
|
// start answering with the version it just installed.
|
|
713
729
|
probe = probeQoderVersionUntil(profile, executable, (candidate) => qoderVersionMeetsMinimum(candidate.version));
|
|
714
730
|
if (!qoderVersionMeetsMinimum(probe.version)) {
|
|
715
|
-
error(`${profile.displayName} requires version 1.1.
|
|
731
|
+
error(`${profile.displayName} requires version 1.1.50 or newer, but ${executable} reports ${probe.version} after updating to latest.`);
|
|
716
732
|
reportQoderVersionProbe(probe);
|
|
717
733
|
return { ok: false, version: probe.version, executable };
|
|
718
734
|
}
|
|
@@ -720,6 +736,38 @@ function ensureMinimumQoderVersionLocked(profile, provider, executable) {
|
|
|
720
736
|
return { ok: true, version: probe.version, executable };
|
|
721
737
|
}
|
|
722
738
|
|
|
739
|
+
// Preserve the 0.3.3 bootstrap behavior on non-Windows platforms.
|
|
740
|
+
function ensureMinimumQoderVersionLocked(profile, provider, executable) {
|
|
741
|
+
if (IS_WIN32) return ensureMinimumQoderVersionWindowsLocked(profile, provider, executable);
|
|
742
|
+
let version = qoderBinaryVersion(executable);
|
|
743
|
+
if (qoderVersionMeetsMinimum(version)) return { ok: true, version };
|
|
744
|
+
|
|
745
|
+
log(`${profile.displayName} ${version} is older than required version 1.1.50 (or could not be parsed). Updating the current installation to latest...`);
|
|
746
|
+
const update = spawnQoderSync(executable, ["update"], { stdio: "inherit", timeout: 5 * 60 * 1000, treeTimeout: true });
|
|
747
|
+
if (update.status !== 0) {
|
|
748
|
+
const managedDir = qoderPaths(profile).managedDir;
|
|
749
|
+
if (executable.startsWith(`${managedDir}${path.sep}`)) {
|
|
750
|
+
const reinstalled = installQoderCLI(provider, { lockHeld: true });
|
|
751
|
+
if (reinstalled) {
|
|
752
|
+
executable = reinstalled;
|
|
753
|
+
version = qoderBinaryVersion(executable);
|
|
754
|
+
if (qoderVersionMeetsMinimum(version)) return { ok: true, version, executable };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const detail = update.error?.message || (update.signal ? `signal ${update.signal}` : `exit status ${update.status}`);
|
|
758
|
+
error(`${profile.displayName} requires version 1.1.50 or newer, but updating ${executable} failed: ${detail}`);
|
|
759
|
+
return { ok: false, version, executable };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
version = qoderBinaryVersion(executable);
|
|
763
|
+
if (!qoderVersionMeetsMinimum(version)) {
|
|
764
|
+
error(`${profile.displayName} requires version 1.1.50 or newer, but ${executable} reports ${version} after updating to latest.`);
|
|
765
|
+
return { ok: false, version, executable };
|
|
766
|
+
}
|
|
767
|
+
log(`${profile.displayName} updated in place to ${version}.`);
|
|
768
|
+
return { ok: true, version, executable };
|
|
769
|
+
}
|
|
770
|
+
|
|
723
771
|
// A missing or unreadable record means "no history", never a bootstrap failure.
|
|
724
772
|
function readQoderState(profile) {
|
|
725
773
|
try {
|
|
@@ -1211,8 +1259,6 @@ async function ensureQoderReady(provider, options = {}) {
|
|
|
1211
1259
|
|
|
1212
1260
|
module.exports = {
|
|
1213
1261
|
QODER_FAMILY_PROFILES,
|
|
1214
|
-
QODER_VERSION_RETRY_DELAYS_MS,
|
|
1215
|
-
classifyQoderVersionProbe,
|
|
1216
1262
|
ensureMinimumQoderVersion,
|
|
1217
1263
|
ensureQoderReady,
|
|
1218
1264
|
findInInstallDirs,
|
|
@@ -1221,7 +1267,6 @@ module.exports = {
|
|
|
1221
1267
|
inspectQoderHealth,
|
|
1222
1268
|
installQoderCLIUnlocked,
|
|
1223
1269
|
isQoderFamilyProvider,
|
|
1224
|
-
probeQoderBinaryVersion,
|
|
1225
1270
|
qoderBinaryVersion,
|
|
1226
1271
|
qoderChildEnv,
|
|
1227
1272
|
qoderUpdateLockPaths,
|
package/bin/token-proxy.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 降级策略:
|
|
15
15
|
* - 所有解析逻辑被 try-catch 包裹,解析失败只丢 token 数据,不断流量
|
|
16
16
|
* - proxy 启动失败时,wrapper 不注入 HTTPS_PROXY,daemon 正常直连
|
|
17
|
-
* -
|
|
17
|
+
* - proxy 运行中崩溃时,Node HTTPS 模块遇到 ECONNREFUSED 会忽略 proxy 直连
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
'use strict';
|
|
@@ -316,53 +316,8 @@ function extractDispatchInfo(req) {
|
|
|
316
316
|
|
|
317
317
|
// ─── Forward Proxy (CONNECT method) ─────────────────────────────────────────
|
|
318
318
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
function guardConnection(sockets, { readyEvents, connectTimeoutMs = 15000,
|
|
322
|
-
idleTimeoutMs = 120000, onCleanup = () => {}, onError = () => {} }) {
|
|
323
|
-
let closed = false;
|
|
324
|
-
const pending = new Set(sockets);
|
|
325
|
-
const cleanup = () => {
|
|
326
|
-
if (closed) return;
|
|
327
|
-
closed = true;
|
|
328
|
-
clearTimeout(connectTimer);
|
|
329
|
-
for (const socket of sockets) {
|
|
330
|
-
socket.setTimeout(0);
|
|
331
|
-
socket.destroy();
|
|
332
|
-
}
|
|
333
|
-
onCleanup();
|
|
334
|
-
};
|
|
335
|
-
const connectTimer = setTimeout(() => {
|
|
336
|
-
onError(new Error('proxy connection setup timed out'));
|
|
337
|
-
cleanup();
|
|
338
|
-
}, connectTimeoutMs);
|
|
339
|
-
connectTimer.unref();
|
|
340
|
-
sockets.forEach((socket, i) => {
|
|
341
|
-
socket.setTimeout(idleTimeoutMs);
|
|
342
|
-
socket.on('timeout', () => {
|
|
343
|
-
onError(new Error('proxy connection idle timeout'));
|
|
344
|
-
cleanup();
|
|
345
|
-
});
|
|
346
|
-
socket.on('close', cleanup);
|
|
347
|
-
socket.on('error', error => { onError(error); cleanup(); });
|
|
348
|
-
// Flush queued bytes before closing the other half. The idle timer still
|
|
349
|
-
// bounds a peer that never completes shutdown.
|
|
350
|
-
socket.on('end', () => {
|
|
351
|
-
for (const peer of sockets) if (peer !== socket) peer.end();
|
|
352
|
-
});
|
|
353
|
-
const readyEvent = readyEvents[i];
|
|
354
|
-
if (!readyEvent) pending.delete(socket);
|
|
355
|
-
else socket.once(readyEvent, () => {
|
|
356
|
-
pending.delete(socket);
|
|
357
|
-
if (!pending.size) clearTimeout(connectTimer);
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
if (!pending.size) clearTimeout(connectTimer);
|
|
361
|
-
return cleanup;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
|
|
365
|
-
connectTimeoutMs = 15000, idleTimeoutMs = 120000, sniffHosts = SNIFF_HOSTS } = {}) {
|
|
319
|
+
function startProxy(port) {
|
|
320
|
+
const ca = ensureCA();
|
|
366
321
|
const dbg = DEBUG ? (...args) => console.log(...args) : () => {};
|
|
367
322
|
|
|
368
323
|
const server = http.createServer((req, res) => {
|
|
@@ -377,26 +332,36 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
|
|
|
377
332
|
const ts = () => new Date().toISOString().slice(11, 23);
|
|
378
333
|
const [hostname, portStr] = req.url.split(':');
|
|
379
334
|
const remotePort = parseInt(portStr) || 443;
|
|
380
|
-
const shouldSniff =
|
|
335
|
+
const shouldSniff = SNIFF_HOSTS.has(hostname);
|
|
381
336
|
const { dispatchId, stepId } = extractDispatchInfo(req);
|
|
382
337
|
|
|
383
338
|
dbg(`[${ts()}] CONNECT #${seq} ${hostname}:${remotePort} sniff=${shouldSniff} dispatch=${dispatchId || 'none'} step=${stepId || 'none'}`);
|
|
384
339
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
340
|
+
if (!shouldSniff) {
|
|
341
|
+
dbg(`[${ts()}] #${seq} → BLIND TUNNEL (not in SNIFF_HOSTS)`);
|
|
342
|
+
const upstream = net.connect(remotePort, hostname, () => {
|
|
343
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
344
|
+
upstream.write(head);
|
|
345
|
+
upstream.pipe(clientSocket);
|
|
346
|
+
clientSocket.pipe(upstream);
|
|
392
347
|
});
|
|
393
|
-
upstream.
|
|
394
|
-
|
|
348
|
+
upstream.on('error', (e) => { dbg(`[${ts()}] #${seq} blind upstream err: ${e.message}`); try { clientSocket.end(); } catch (_) {} });
|
|
349
|
+
clientSocket.on('error', (e) => { dbg(`[${ts()}] #${seq} blind client err: ${e.message}`); try { upstream.end(); } catch (_) {} });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// MITM for Qoder API hosts: decrypt, sniff usage, re-encrypt
|
|
354
|
+
const hostCert = getHostCert(hostname, ca);
|
|
355
|
+
if (!hostCert) {
|
|
356
|
+
dbg(`[${ts()}] #${seq} → BLIND TUNNEL (cert generation failed for ${hostname})`);
|
|
357
|
+
const upstream = net.connect(remotePort, hostname, () => {
|
|
395
358
|
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
396
|
-
|
|
359
|
+
upstream.write(head);
|
|
397
360
|
upstream.pipe(clientSocket);
|
|
398
361
|
clientSocket.pipe(upstream);
|
|
399
362
|
});
|
|
363
|
+
upstream.on('error', (e) => { dbg(`[${ts()}] #${seq} cert-fail upstream err: ${e.message}`); try { clientSocket.end(); } catch (_) {} });
|
|
364
|
+
clientSocket.on('error', (e) => { dbg(`[${ts()}] #${seq} cert-fail client err: ${e.message}`); try { upstream.end(); } catch (_) {} });
|
|
400
365
|
return;
|
|
401
366
|
}
|
|
402
367
|
|
|
@@ -405,8 +370,6 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
|
|
|
405
370
|
// Accept TLS from client (presenting our per-host cert signed by our CA)
|
|
406
371
|
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
407
372
|
|
|
408
|
-
// CONNECT head contains encrypted TLS bytes, not plaintext HTTP.
|
|
409
|
-
if (head.length) clientSocket.unshift(head);
|
|
410
373
|
const tlsClient = new tls.TLSSocket(clientSocket, {
|
|
411
374
|
isServer: true,
|
|
412
375
|
key: hostCert.key,
|
|
@@ -422,6 +385,7 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
|
|
|
422
385
|
let tlsUpstreamReady = false;
|
|
423
386
|
tlsUpstream.on('secureConnect', () => { tlsUpstreamReady = true; dbg(`[${ts()}] #${seq} TLS-upstream handshake OK → ${hostname}`); });
|
|
424
387
|
|
|
388
|
+
let responseBuffer = Buffer.alloc(0);
|
|
425
389
|
let usageCount = 0;
|
|
426
390
|
let totalBytes = 0;
|
|
427
391
|
let rawFd = null;
|
|
@@ -465,6 +429,7 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
|
|
|
465
429
|
|
|
466
430
|
tlsUpstream.on('data', (chunk) => {
|
|
467
431
|
totalBytes += chunk.length;
|
|
432
|
+
try { tlsClient.write(chunk); } catch (_) {}
|
|
468
433
|
if (rawFd !== null) { try { fs.writeSync(rawFd, chunk); } catch (_) {} }
|
|
469
434
|
try {
|
|
470
435
|
const text = lineRemainder + chunk.toString('utf8');
|
|
@@ -479,9 +444,9 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
|
|
|
479
444
|
} catch (_) {}
|
|
480
445
|
});
|
|
481
446
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
447
|
+
tlsClient.on('data', (chunk) => {
|
|
448
|
+
try { tlsUpstream.write(chunk); } catch (_) {}
|
|
449
|
+
});
|
|
485
450
|
|
|
486
451
|
const cleanup = () => {
|
|
487
452
|
// Final extraction from any remaining data (lineRemainder may hold last line without trailing \n)
|
|
@@ -491,24 +456,18 @@ function createProxyServer({ ca = ensureCA(), hostCertificate = getHostCert,
|
|
|
491
456
|
lineRemainder = '';
|
|
492
457
|
dbg(`[${ts()}] #${seq} CLOSED ${hostname} bytes=${totalBytes} usages=${usageCount} tlsClient=${tlsClientReady} tlsUpstream=${tlsUpstreamReady}`);
|
|
493
458
|
if (rawFd !== null) { try { fs.closeSync(rawFd); } catch (_) {} rawFd = null; }
|
|
459
|
+
try { tlsClient.end(); } catch (_) {}
|
|
460
|
+
try { tlsUpstream.end(); } catch (_) {}
|
|
494
461
|
};
|
|
495
462
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
});
|
|
501
|
-
// Also fence an aborted raw CONNECT socket during TLS setup.
|
|
502
|
-
clientSocket.on('close', closeConnection);
|
|
503
|
-
clientSocket.on('error', closeConnection);
|
|
463
|
+
tlsClient.on('end', cleanup);
|
|
464
|
+
tlsClient.on('error', (e) => { dbg(`[${ts()}] #${seq} TLS-client ERROR: ${e.message}`); cleanup(); });
|
|
465
|
+
tlsUpstream.on('end', cleanup);
|
|
466
|
+
tlsUpstream.on('error', (e) => { dbg(`[${ts()}] #${seq} TLS-upstream ERROR: ${e.message}`); cleanup(); });
|
|
504
467
|
|
|
468
|
+
if (head.length > 0) tlsClient.emit('data', head);
|
|
505
469
|
});
|
|
506
470
|
|
|
507
|
-
return server;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function startProxy(port) {
|
|
511
|
-
const server = createProxyServer();
|
|
512
471
|
server.on('error', (err) => {
|
|
513
472
|
console.error(`[token-proxy] server error: ${err.message}`);
|
|
514
473
|
process.exit(1);
|
|
@@ -595,30 +554,29 @@ function parseDuration(s) {
|
|
|
595
554
|
const args = process.argv.slice(2);
|
|
596
555
|
const command = args[0];
|
|
597
556
|
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
Usage log: ${USAGE_LOG_PATH}`);
|
|
620
|
-
}
|
|
557
|
+
if (command === 'start') {
|
|
558
|
+
const portIdx = args.indexOf('--port');
|
|
559
|
+
const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : DEFAULT_PORT;
|
|
560
|
+
startProxy(port);
|
|
561
|
+
} else if (command === 'query') {
|
|
562
|
+
const jsonFlag = args.includes('--json');
|
|
563
|
+
const sinceIdx = args.indexOf('--since');
|
|
564
|
+
const since = sinceIdx >= 0 ? args[sinceIdx + 1] : '24h';
|
|
565
|
+
queryUsage({ json: jsonFlag, since });
|
|
566
|
+
} else {
|
|
567
|
+
console.log(`Usage:
|
|
568
|
+
node token-proxy.js start [--port ${DEFAULT_PORT}] Start the proxy
|
|
569
|
+
node token-proxy.js query [--since 1h] [--json] Query token usage
|
|
570
|
+
|
|
571
|
+
Environment (set before starting daemon):
|
|
572
|
+
HTTPS_PROXY=http://127.0.0.1:${DEFAULT_PORT}
|
|
573
|
+
NODE_EXTRA_CA_CERTS=${CA_CERT_PATH}
|
|
574
|
+
|
|
575
|
+
Files:
|
|
576
|
+
CA cert: ${CA_CERT_PATH}
|
|
577
|
+
Usage log: ${USAGE_LOG_PATH}`);
|
|
621
578
|
}
|
|
622
579
|
|
|
623
|
-
|
|
624
|
-
|
|
580
|
+
if (require.main !== module) {
|
|
581
|
+
module.exports = { extractUsageFromResponse, extractDispatchInfo, buildUsageRecord };
|
|
582
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|