autowonder 0.2.154 → 0.2.156
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 +37 -9
- package/bin/cli.js +1 -0
- package/bin/process-tree-runner.js +52 -0
- package/bin/qoder-bootstrap.js +369 -22
- package/bin/windows-job-runner.ps1 +126 -0
- 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,14 +6,16 @@ AutoWonder 本地 agent runtime。安装后启动 daemon,持续轮询本地 as
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# Qoder CLI(模型使用 provider model ID;Context Window 使用固定档位)
|
|
9
|
-
npx -y autowonder@0.2.
|
|
9
|
+
npx -y autowonder@0.2.156 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.2.
|
|
12
|
+
npx -y autowonder@0.2.156 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 时需要 Node.js 20+;runtime 会在缺少 `qodercli` 时自动通过 npm 安装,并在尚未登录时打开 Qoder 浏览器登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
|
|
16
16
|
|
|
17
|
+
`connect` 和 `start` 都要求 Qoder CLI / Qoder CLI CN 版本至少为 `1.1.50`。检测到旧版或无法解析的版本时,runtime 会对当前优先选中的用户 CLI 执行内置 `update`,原位升级到最新版并再次强校验;升级失败或升级后仍不满足最低版本时拒绝启动 daemon。所有 Qoder 系列子进程(普通任务、conversation、warm 会话、恢复回退、命令与模型探测)都会强制注入固定的 `QODER_INTEGRATION_ID`,调用方环境不能覆盖该流量归因标识。
|
|
18
|
+
|
|
17
19
|
`qodercn` 是国内版 Qoder CLI。runtime 会在缺少 `qodercn` 时自动把 `@qodercn-ai/qoderclicn` 安装到 `~/.autowonder/qoder-cn-cli`,并在尚未登录时打开浏览器登录。它使用自己的配置根(`QODERCN_CONFIG_DIR`,默认 `~/.qoder-cn`),与国际版 `qoder` 不共享可执行文件、凭证和会话。可用 `AUTOWONDER_QODERCN_CLI` 指定自定义可执行文件路径。
|
|
18
20
|
|
|
19
21
|
Qoder 的 Context Window 只能使用 `1000000`、`400000` 或 `260000`,Reasoning Effort 只能使用 `none`、`low`、`medium`、`high`、`xhigh` 或 `max`。`--model` 不再校验固定白名单:取值经 `AUTOWONDER_MODEL` 原样透传给 Qoder CLI,因此服务端上线新模型无需升级本客户端。建议直接复制 AutoWonder 执行器页面生成的命令,避免把模型展示名称误当成 provider model ID。
|
|
@@ -26,6 +28,28 @@ daemon 还会响应服务端的 `CONVERSATION_COMMANDS_PROBE` 帧,用独立会
|
|
|
26
28
|
|
|
27
29
|
`connect` 支持 `--workspace-root <path>` 指定 dispatch 仓库与任务工作区所在目录(默认 `~/autowonder_workspaces`)。该参数会转发给 daemon,等价于设置 `AUTOWONDER_WORKSPACE_ROOT` 环境变量,适合把工作区放在非默认路径的执行机上(如 Windows 的 `D:\autowonder`)。
|
|
28
30
|
|
|
31
|
+
## 工作区保留与 Git 对象复用
|
|
32
|
+
|
|
33
|
+
daemon 默认启用本地工作区 GC:启动 30 秒后开始检查,之后每小时执行有限量的后台清理。非会话任务仅在终态且最后活动时间超过所配置的任务 TTL(默认 72 小时)后才符合清理条件;会话目录仅在最后活动时间超过所配置的会话 TTL(默认 720 小时)后才符合条件。清理的单位是整个 attempt capsule 或整个 conversation capsule,不会只删除其中部分文件。未知或格式异常、非终态(包括 uploading)、仍在运行、由恢复流程持有、结果仍待确认,或结果状态查询失败的 capsule 都会保留。
|
|
34
|
+
|
|
35
|
+
磁盘使用率达到 85% 或可用空间低于 10 GiB 时进入压力模式,恢复目标是使用率不高于 75% 且可用空间至少 15 GiB。压力只会调整有限量清理工作的调度和顺序;它不会放宽所配置的任务/会话 TTL(默认 72/720 小时),也不会让较新的 capsule 提前被删除。
|
|
36
|
+
|
|
37
|
+
相关环境变量及默认值如下:
|
|
38
|
+
|
|
39
|
+
- `AUTOWONDER_WORKSPACE_GC_ENABLED=true`
|
|
40
|
+
- `AUTOWONDER_WORKSPACE_GC_INITIAL_DELAY=30s`
|
|
41
|
+
- `AUTOWONDER_WORKSPACE_GC_INTERVAL=1h`
|
|
42
|
+
- `AUTOWONDER_WORKSPACE_GC_TASK_TTL=72h`
|
|
43
|
+
- `AUTOWONDER_WORKSPACE_GC_CONVERSATION_TTL=720h`
|
|
44
|
+
- `AUTOWONDER_WORKSPACE_GC_HIGH_WATERMARK_PERCENT=85`
|
|
45
|
+
- `AUTOWONDER_WORKSPACE_GC_LOW_WATERMARK_PERCENT=75`
|
|
46
|
+
- `AUTOWONDER_WORKSPACE_GC_MIN_FREE_BYTES=10737418240`(10 GiB)
|
|
47
|
+
- `AUTOWONDER_WORKSPACE_GC_TARGET_FREE_BYTES=16106127360`(15 GiB)
|
|
48
|
+
|
|
49
|
+
将 `AUTOWONDER_WORKSPACE_GC_ENABLED` 设为 `false` 只会禁用自主 GC;服务端下发的 `WORKITEM_WORKSPACE_CLEANUP` 清理仍按原有生命周期执行。
|
|
50
|
+
|
|
51
|
+
`~/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 也不会删除该缓存。
|
|
52
|
+
|
|
29
53
|
## 自动更新
|
|
30
54
|
|
|
31
55
|
安装首个支持自动更新的正式版本后,daemon 会直接查询 npm 的 `autowonder/latest`。首次检查会随机分散在启动后的 30~90 秒,之后约每 5 分钟检查一次并加入随机抖动;npm 临时错误会指数退避,HTTP 429/503 的 `Retry-After` 会被遵循。发现更高的稳定版本时,它会在后台下载 npm tarball,校验 SHA-512 SRI、包版本和平台二进制;只有在没有运行/恢复任务且结果 outbox 已收到服务端确认时,才会暂停接单、原子切换二进制并自动重启。启动失败会恢复上一个二进制。
|
|
@@ -44,6 +68,10 @@ npx -y autowonder@latest connect ...
|
|
|
44
68
|
|
|
45
69
|
候选版本缓存在 `~/.autowonder/updates/`;当前二进制和单个回滚副本位于 `~/.autowonder/bin/`。`GET /health` 会返回 `daemonVersion`、`updateState`、`updateTarget` 和 `updateError`。
|
|
46
70
|
|
|
71
|
+
daemon 建立连接时和每次心跳定时检查时发出的 `HEARTBEAT` 帧都带上 `version` 字段,取值为该二进制构建时注入的发布版本号(`currentReleaseIdentity().Version`,与 `--self-check` 报告的版本同源);版本为空时该字段不出现在帧里。服务端据此记录每台执行机实际运行的客户端版本并在执行器列表展示,为后续客户端批量升级提供数据基础。对执行机用户无新增参数或环境变量。
|
|
72
|
+
|
|
73
|
+
同一批 `HEARTBEAT` 帧还带上进程级固定的 `startedAt`(重连不重置)与继承自 re-exec 的 `restartRequestId`,并在能力位里声明 `EXECUTOR_RESTART_V1`(Windows 不声明);接入自动更新的 daemon 额外声明 `EXECUTOR_UPDATE_RESTART_V1`。服务端可据此下发 `EXECUTOR_RESTART`(带 `executorId`、`requestId`、ISO UTC `issuedAt` 与布尔 `update`)。本机只接受 `executorId` 与自身一致、未过期且未处理过的请求,不接收任何远端 shell 命令、可执行路径或下载地址,并以 `EXECUTOR_RESTART_RESULT`(`UPDATING` / `RESTARTING` / `FAILED`)回执,真正是否成功以下一次心跳为准。重启走既有的接单闸门:有运行中任务或结果 outbox 未确认可读时不重启,re-exec 失败会重新开放接单;`update=true` 复用既有的 registry、完整性校验、候选 self-check、安装事务与回滚,仅标准安装的正式版支持远端拉取新版本,已是最新版的请求仍会重启,且该操作不应用平台上保存的启动配置改动。对执行机用户同样无新增参数或环境变量。
|
|
74
|
+
|
|
47
75
|
## 发布构建
|
|
48
76
|
|
|
49
77
|
`npm run build` 和发布脚本只接受没有已跟踪或未跟踪改动的 Git worktree。脚本会把准确的 release commit 克隆到仓库外的 detached checkout,再从该目录构建六个平台(darwin/linux/windows × amd64/arm64)的 daemon。
|
|
@@ -64,7 +92,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
|
|
|
64
92
|
也可以直接提交到本地 API:
|
|
65
93
|
|
|
66
94
|
```bash
|
|
67
|
-
npx -y autowonder@0.2.
|
|
95
|
+
npx -y autowonder@0.2.156 dispatch ./assignment.json
|
|
68
96
|
```
|
|
69
97
|
|
|
70
98
|
## 上传工单附件
|
|
@@ -72,7 +100,7 @@ npx -y autowonder@0.2.154 dispatch ./assignment.json
|
|
|
72
100
|
把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
|
|
73
101
|
|
|
74
102
|
```bash
|
|
75
|
-
npx -y autowonder@0.2.
|
|
103
|
+
npx -y autowonder@0.2.156 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
76
104
|
```
|
|
77
105
|
|
|
78
106
|
上传令牌通过 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)。
|
|
@@ -82,7 +110,7 @@ npx -y autowonder@0.2.154 workitem upload --server-url <autowonder-server-url> -
|
|
|
82
110
|
把工单上已有的需求/设计文档下载到本地目录,再用本地文件工具阅读,避免把大文件或二进制附件搬进会话上下文:
|
|
83
111
|
|
|
84
112
|
```bash
|
|
85
|
-
npx -y autowonder@0.2.
|
|
113
|
+
npx -y autowonder@0.2.156 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
|
|
86
114
|
```
|
|
87
115
|
|
|
88
116
|
下载令牌通过 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)。
|
|
@@ -92,7 +120,7 @@ npx -y autowonder@0.2.154 workitem download --server-url <autowonder-server-url>
|
|
|
92
120
|
把需求/设计文档上传到 7x24 定时任务,用法与工单附件一致(同一套上传令牌与预检规则):
|
|
93
121
|
|
|
94
122
|
```bash
|
|
95
|
-
npx -y autowonder@0.2.
|
|
123
|
+
npx -y autowonder@0.2.156 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
96
124
|
```
|
|
97
125
|
|
|
98
126
|
上传令牌同样通过 `autowonder.workitem_cli_upload_token` 签发(令牌为用户级,对当前可修改的任意定时任务生效),经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 传入;退出码约定与 `workitem upload` 相同。
|
|
@@ -100,8 +128,8 @@ npx -y autowonder@0.2.154 scheduled-task upload --server-url <autowonder-server-
|
|
|
100
128
|
## 管理 daemon
|
|
101
129
|
|
|
102
130
|
```bash
|
|
103
|
-
npx -y autowonder@0.2.
|
|
104
|
-
npx -y autowonder@0.2.
|
|
131
|
+
npx -y autowonder@0.2.156 status
|
|
132
|
+
npx -y autowonder@0.2.156 stop
|
|
105
133
|
```
|
|
106
134
|
|
|
107
135
|
默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
|
|
@@ -109,7 +137,7 @@ npx -y autowonder@0.2.154 stop
|
|
|
109
137
|
## 调试
|
|
110
138
|
|
|
111
139
|
```bash
|
|
112
|
-
npx -y autowonder@0.2.
|
|
140
|
+
npx -y autowonder@0.2.156 connect ... --debug
|
|
113
141
|
```
|
|
114
142
|
|
|
115
143
|
`--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
|
package/bin/cli.js
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Unix process-tree runner. Windows uses windows-job-runner.ps1 so the target
|
|
5
|
+
// is created suspended, assigned to a kill-on-close Job Object, then resumed.
|
|
6
|
+
const { spawn } = require("child_process");
|
|
7
|
+
|
|
8
|
+
const timeout = Number.parseInt(process.argv[2], 10);
|
|
9
|
+
const executable = process.argv[3];
|
|
10
|
+
const args = JSON.parse(process.argv[4] || "[]");
|
|
11
|
+
const child = spawn(executable, args, {
|
|
12
|
+
env: process.env,
|
|
13
|
+
stdio: "inherit",
|
|
14
|
+
shell: false,
|
|
15
|
+
detached: true,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
let timedOut = false;
|
|
19
|
+
let terminated = false;
|
|
20
|
+
function terminateTree() {
|
|
21
|
+
if (terminated || !child.pid) return;
|
|
22
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
|
23
|
+
terminated = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
timedOut = true;
|
|
28
|
+
terminateTree();
|
|
29
|
+
}, timeout);
|
|
30
|
+
|
|
31
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
32
|
+
process.on(signal, () => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
terminateTree();
|
|
35
|
+
process.exit(128);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
child.on("error", (err) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
terminateTree();
|
|
42
|
+
console.error(err.message);
|
|
43
|
+
process.exit(127);
|
|
44
|
+
});
|
|
45
|
+
child.on("exit", (code, signal) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
terminateTree();
|
|
48
|
+
if (timedOut) process.exit(124);
|
|
49
|
+
if (Number.isInteger(code)) process.exit(code);
|
|
50
|
+
console.error(`process terminated by ${signal || "unknown signal"}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
package/bin/qoder-bootstrap.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const { spawnSync } = require("child_process");
|
|
4
|
+
const crypto = require("crypto");
|
|
4
5
|
const fs = require("fs");
|
|
5
6
|
const os = require("os");
|
|
6
7
|
const path = require("path");
|
|
@@ -8,6 +9,29 @@ const path = require("path");
|
|
|
8
9
|
const { ensureDir, error, findOnPath, log, resolveUserPath } = require("./shared");
|
|
9
10
|
|
|
10
11
|
const IS_WIN32 = process.platform === "win32";
|
|
12
|
+
const QODER_INTEGRATION_ID_ENV = "QODER_INTEGRATION_ID";
|
|
13
|
+
const LEGACY_QODER_HOST_SERVICE_NAME_ENV = "QODER_HOST_SERVICE_NAME";
|
|
14
|
+
// AutoWonder's permanent, unique service identity for traffic reported to
|
|
15
|
+
// Qoder. DO NOT CHANGE this UUID after publication: changing it would split
|
|
16
|
+
// historical and future attribution. It is not configurable or regenerated.
|
|
17
|
+
const QODER_INTEGRATION_ID = "5b5d9168-09e0-4b8a-92f5-2ee7cd817fed";
|
|
18
|
+
const QODER_MINIMUM_VERSION = [1, 1, 50];
|
|
19
|
+
const QODER_LOCK_MAX_PID = 2147483647;
|
|
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
|
+
const PROCESS_TREE_RUNNER = path.join(__dirname, "process-tree-runner.js");
|
|
22
|
+
const WINDOWS_JOB_RUNNER = path.join(__dirname, "windows-job-runner.ps1");
|
|
23
|
+
|
|
24
|
+
function spawnSyncWithTreeTimeout(executable, args, options) {
|
|
25
|
+
const timeout = options.timeout;
|
|
26
|
+
const runnerOptions = { ...options, timeout: timeout + 30000 };
|
|
27
|
+
if (IS_WIN32) {
|
|
28
|
+
const powershell = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
29
|
+
const encodedArgs = Buffer.from(JSON.stringify(args), "utf8").toString("base64");
|
|
30
|
+
return spawnSync(powershell, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File",
|
|
31
|
+
WINDOWS_JOB_RUNNER, "-TimeoutMs", String(timeout), "-Executable", executable, "-ArgsBase64", encodedArgs], runnerOptions);
|
|
32
|
+
}
|
|
33
|
+
return spawnSync(process.execPath, [PROCESS_TREE_RUNNER, String(timeout), executable, JSON.stringify(args)], runnerOptions);
|
|
34
|
+
}
|
|
11
35
|
|
|
12
36
|
// qoder and qodercn are two distinct provider identities that share the same
|
|
13
37
|
// bootstrap flow. Each identity keeps its own executable, npm package, managed
|
|
@@ -68,11 +92,35 @@ function isQoderFamilyProvider(provider) {
|
|
|
68
92
|
// Quotes one argument for cmd.exe. Paths containing spaces must stay a single
|
|
69
93
|
// token when a .cmd shim is launched through ComSpec.
|
|
70
94
|
function quoteWindowsArg(arg) {
|
|
71
|
-
|
|
72
|
-
return `"${
|
|
95
|
+
const value = String(arg);
|
|
96
|
+
return `"${value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/g, "$1$1")}"`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function windowsQoderShimInvocation(executable, args, baseEnv) {
|
|
100
|
+
const env = {};
|
|
101
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
102
|
+
if (!key.toLowerCase().startsWith("autowonder_qoder_shim_path")) env[key] = value;
|
|
103
|
+
}
|
|
104
|
+
env.AUTOWONDER_QODER_SHIM_PATH = executable;
|
|
105
|
+
// cmd expands environment variables once. Keeping the executable in an env
|
|
106
|
+
// value prevents %, &, ^ and other legal path characters from becoming
|
|
107
|
+
// command source; the surrounding quotes keep the expanded path one token.
|
|
108
|
+
const inner = [`"%AUTOWONDER_QODER_SHIM_PATH%"`, ...args.map(quoteWindowsArg)].join(" ");
|
|
109
|
+
return { env, command: `"${inner}"` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function qoderChildEnv(extra = {}) {
|
|
113
|
+
const env = { ...process.env, ...extra };
|
|
114
|
+
for (const key of Object.keys(env)) {
|
|
115
|
+
if (key.toLowerCase() === LEGACY_QODER_HOST_SERVICE_NAME_ENV.toLowerCase()
|
|
116
|
+
|| key.toLowerCase() === QODER_INTEGRATION_ID_ENV.toLowerCase()) delete env[key];
|
|
117
|
+
}
|
|
118
|
+
env[QODER_INTEGRATION_ID_ENV] = QODER_INTEGRATION_ID;
|
|
119
|
+
return env;
|
|
73
120
|
}
|
|
74
121
|
|
|
75
122
|
function spawnQoderSync(executable, args, options = {}) {
|
|
123
|
+
const env = qoderChildEnv(options.env);
|
|
76
124
|
// npm exposes package binaries as .cmd shims on Windows. CreateProcess
|
|
77
125
|
// cannot execute those shims directly, but shell:true would join the
|
|
78
126
|
// executable and its arguments with bare spaces and shred any path that
|
|
@@ -81,10 +129,19 @@ function spawnQoderSync(executable, args, options = {}) {
|
|
|
81
129
|
// plain argument array with no shell involved.
|
|
82
130
|
if (IS_WIN32 && /\.(cmd|bat)$/i.test(executable)) {
|
|
83
131
|
const comspec = process.env.ComSpec || "cmd.exe";
|
|
84
|
-
const
|
|
85
|
-
|
|
132
|
+
const invocation = windowsQoderShimInvocation(executable, args, env);
|
|
133
|
+
const spawnArgs = ["/d", "/v:off", "/s", "/c", invocation.command];
|
|
134
|
+
if (options.treeTimeout) {
|
|
135
|
+
const { treeTimeout: _, ...treeOptions } = options;
|
|
136
|
+
return spawnSyncWithTreeTimeout(comspec, spawnArgs, { ...treeOptions, env: invocation.env });
|
|
137
|
+
}
|
|
138
|
+
return spawnSync(comspec, spawnArgs, { ...options, env: invocation.env });
|
|
139
|
+
}
|
|
140
|
+
if (options.treeTimeout) {
|
|
141
|
+
const { treeTimeout: _, ...treeOptions } = options;
|
|
142
|
+
return spawnSyncWithTreeTimeout(executable, args, { ...treeOptions, env, shell: false });
|
|
86
143
|
}
|
|
87
|
-
return spawnSync(executable, args, { ...options, shell: false });
|
|
144
|
+
return spawnSync(executable, args, { ...options, env, shell: false });
|
|
88
145
|
}
|
|
89
146
|
|
|
90
147
|
// Roots that hold user-level installers on Windows. Kept env-driven so the
|
|
@@ -220,18 +277,49 @@ function findQoderExecutable(provider) {
|
|
|
220
277
|
return "";
|
|
221
278
|
}
|
|
222
279
|
|
|
223
|
-
function
|
|
280
|
+
function managedQoderExecutable(profile) {
|
|
281
|
+
const binName = IS_WIN32 ? profile.windowsShimName : profile.managedBinName;
|
|
282
|
+
return path.join(qoderPaths(profile).managedDir, "node_modules", ".bin", binName);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function installQoderCLI(provider, options = {}) {
|
|
286
|
+
const profile = QODER_FAMILY_PROFILES[provider];
|
|
287
|
+
if (options.lockHeld) return installQoderCLIUnlocked(provider);
|
|
288
|
+
const result = withQoderUpdateLock(managedQoderExecutable(profile), () => {
|
|
289
|
+
if (options.skipIfPresent) {
|
|
290
|
+
const existing = findQoderExecutable(provider);
|
|
291
|
+
if (existing) return existing;
|
|
292
|
+
}
|
|
293
|
+
return installQoderCLIUnlocked(provider);
|
|
294
|
+
});
|
|
295
|
+
return result || "";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function installQoderCLIUnlocked(provider) {
|
|
224
299
|
const profile = QODER_FAMILY_PROFILES[provider];
|
|
225
300
|
const managedDir = qoderPaths(profile).managedDir;
|
|
226
301
|
log(`${profile.displayName} is not installed. Installing ${profile.npmPackage}...`);
|
|
227
302
|
const args = [
|
|
228
303
|
"install", "--prefix", managedDir, profile.npmPackage, "--no-audit", "--no-fund",
|
|
229
304
|
];
|
|
305
|
+
const installDeadline = Date.now() + 5 * 60 * 1000;
|
|
230
306
|
// npm is a .cmd shim on Windows. When launched by npx, npm_execpath points
|
|
231
307
|
// at npm-cli.js, which Node can execute directly without cmd.exe quoting.
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
308
|
+
const installEnv = qoderChildEnv();
|
|
309
|
+
const npmExecPath = IS_WIN32 ? process.env.npm_execpath : "";
|
|
310
|
+
const npmShim = IS_WIN32 && !npmExecPath ? findOnPath("npm.cmd") : "";
|
|
311
|
+
const npmExecutable = IS_WIN32 && !npmExecPath && !npmShim ? findOnPath("npm.exe") : "";
|
|
312
|
+
if (IS_WIN32 && !npmExecPath && !npmShim && !npmExecutable) {
|
|
313
|
+
error(`Failed to install ${profile.displayName}: neither npm.cmd nor npm.exe was found on PATH and npm_execpath is not set.`);
|
|
314
|
+
return "";
|
|
315
|
+
}
|
|
316
|
+
const runNpm = (npmArgs) => {
|
|
317
|
+
const common = { env: installEnv, stdio: "inherit", timeout: Math.max(1, installDeadline - Date.now()) };
|
|
318
|
+
if (npmExecPath) return spawnSyncWithTreeTimeout(process.execPath, [npmExecPath, ...npmArgs], common);
|
|
319
|
+
if (npmShim) return spawnQoderSync(npmShim, npmArgs, { ...common, treeTimeout: true });
|
|
320
|
+
if (npmExecutable) return spawnSyncWithTreeTimeout(npmExecutable, npmArgs, common);
|
|
321
|
+
return spawnSyncWithTreeTimeout("npm", npmArgs, common);
|
|
322
|
+
};
|
|
235
323
|
let result = runNpm(args);
|
|
236
324
|
if (result.status !== 0 && IS_WIN32) {
|
|
237
325
|
log(`Default npm registry failed; retrying ${profile.displayName} installation with npmmirror...`);
|
|
@@ -250,22 +338,261 @@ function installQoderCLI(provider) {
|
|
|
250
338
|
const MACHINE_ID_REL_PATH = path.join(".auth", "machine_id");
|
|
251
339
|
const CREDENTIAL_REL_PATH = path.join(".auth", "user");
|
|
252
340
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
function qoderBinaryVersion(executable) {
|
|
341
|
+
function qoderBinaryVersion(executable, options = {}) {
|
|
342
|
+
if ((path.isAbsolute(executable) || executable.includes(path.sep)) && !fs.existsSync(executable)) return "missing";
|
|
256
343
|
const result = spawnQoderSync(executable, ["--version"], {
|
|
257
344
|
encoding: "utf8",
|
|
258
345
|
stdio: ["ignore", "pipe", "pipe"],
|
|
259
|
-
timeout: 20000,
|
|
346
|
+
timeout: options.timeout || 20000,
|
|
347
|
+
treeTimeout: true,
|
|
260
348
|
});
|
|
261
349
|
// "missing" means the file itself is absent; "unknown" means the file is
|
|
262
350
|
// there but produced no version, so the two failure classes never collapse
|
|
263
351
|
// into one misleading answer.
|
|
264
352
|
if (result.error && result.error.code === "ENOENT") return "missing";
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
353
|
+
if (result.status !== 0) return "unknown";
|
|
354
|
+
// Accept only an unambiguous Qoder version line. Runtime warnings and update
|
|
355
|
+
// banners often contain unrelated/newer semvers and must never satisfy the
|
|
356
|
+
// minimum gate for an old executable.
|
|
357
|
+
return qoderVersionLine(`${result.stdout || ""}\n${result.stderr || ""}`) || "unknown";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function qoderVersionLine(output) {
|
|
361
|
+
const matches = String(output || "").split(/\r?\n/).map((line) => line.trim()).filter((line) =>
|
|
362
|
+
QODER_VERSION_LINE_RE.test(line));
|
|
363
|
+
return matches.length === 1 ? matches[0] : "";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function qoderVersionMeetsMinimum(output) {
|
|
367
|
+
const line = qoderVersionLine(output);
|
|
368
|
+
if (!line) return false;
|
|
369
|
+
const match = line.match(QODER_VERSION_LINE_RE);
|
|
370
|
+
const actual = match.slice(1, 4).map((part) => Number.parseInt(part, 10));
|
|
371
|
+
for (let i = 0; i < QODER_MINIMUM_VERSION.length; i += 1) {
|
|
372
|
+
if (actual[i] > QODER_MINIMUM_VERSION[i]) return true;
|
|
373
|
+
if (actual[i] < QODER_MINIMUM_VERSION[i]) return false;
|
|
374
|
+
}
|
|
375
|
+
// A prerelease of 1.1.50 is lower than the released minimum.
|
|
376
|
+
return !match[4];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function qoderUpdateLockPaths(executable) {
|
|
380
|
+
let lexical = path.resolve(executable);
|
|
381
|
+
if (IS_WIN32) lexical = lexical.toLowerCase();
|
|
382
|
+
const keys = [lexical];
|
|
383
|
+
try {
|
|
384
|
+
let resolved = fs.realpathSync.native(lexical);
|
|
385
|
+
if (IS_WIN32) resolved = resolved.toLowerCase();
|
|
386
|
+
if (resolved !== lexical) keys.push(resolved);
|
|
387
|
+
} catch { /* the stable lexical key protects installation before the shim exists */ }
|
|
388
|
+
return keys.map((key) => {
|
|
389
|
+
const digest = crypto.createHash("sha256").update(key).digest("hex");
|
|
390
|
+
return path.join(os.homedir(), ".autowonder", "locks", `autowonder-qoder-update-${digest}.lock`);
|
|
391
|
+
}).sort();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function qoderOwnerState(pid) {
|
|
395
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return "unknown";
|
|
396
|
+
try { process.kill(pid, 0); return "alive"; }
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (err && err.code === "ESRCH") return "dead";
|
|
399
|
+
return "unknown";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function qoderLockArtifactError(kind, artifact) {
|
|
404
|
+
const err = new Error(`Qoder update ${kind} ${artifact} is not a regular file; remove it manually only after confirming no updater is running.`);
|
|
405
|
+
err.code = "AUTOWONDER_QODER_LOCK_NOT_REGULAR";
|
|
406
|
+
return err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function qoderLockTickets(lockPath) {
|
|
410
|
+
let maximum = 0n;
|
|
411
|
+
const tickets = [];
|
|
412
|
+
for (const name of fs.readdirSync(lockPath)) {
|
|
413
|
+
if (!name.endsWith(".ticket")) continue;
|
|
414
|
+
const artifact = path.join(lockPath, name);
|
|
415
|
+
let info;
|
|
416
|
+
try { info = fs.lstatSync(artifact); }
|
|
417
|
+
catch (err) { if (err && err.code === "ENOENT") continue; throw err; }
|
|
418
|
+
if (!info.isFile()) throw qoderLockArtifactError("ticket", artifact);
|
|
419
|
+
const match = /^(\d{20})-([1-9]\d*)-([0-9a-f]{32})\.ticket$/.exec(name);
|
|
420
|
+
if (!match) throw new Error(`Qoder update ticket ${artifact} is malformed; remove it manually only after confirming no updater is running.`);
|
|
421
|
+
const sequence = BigInt(match[1]);
|
|
422
|
+
if (sequence > maximum) maximum = sequence;
|
|
423
|
+
tickets.push(artifact);
|
|
424
|
+
}
|
|
425
|
+
tickets.sort();
|
|
426
|
+
return { tickets, maximum };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function writeQoderLockTicket(lockPath, sequence, owner) {
|
|
430
|
+
const name = `${String(sequence).padStart(20, "0")}-${owner.pid}-${owner.token}.ticket`;
|
|
431
|
+
const temporary = path.join(lockPath, `.${name}.tmp`);
|
|
432
|
+
const ticket = path.join(lockPath, name);
|
|
433
|
+
try {
|
|
434
|
+
fs.writeFileSync(temporary, JSON.stringify(owner), { mode: 0o600 });
|
|
435
|
+
fs.renameSync(temporary, ticket);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* preserve the publication error */ }
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
441
|
+
return ticket;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function writeQoderChoosingMarker(lockPath, owner) {
|
|
445
|
+
const marker = path.join(lockPath, `choosing-${owner.pid}-${owner.token}`);
|
|
446
|
+
try { fs.writeFileSync(marker, JSON.stringify(owner), { mode: 0o600, flag: "wx" }); }
|
|
447
|
+
catch (err) {
|
|
448
|
+
try { fs.rmSync(marker, { force: true }); } catch { /* preserve the creation error */ }
|
|
449
|
+
throw err;
|
|
450
|
+
}
|
|
451
|
+
return marker;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function qoderChoosingMarkers(lockPath) {
|
|
455
|
+
const markers = [];
|
|
456
|
+
for (const name of fs.readdirSync(lockPath)) {
|
|
457
|
+
if (!name.startsWith("choosing-")) continue;
|
|
458
|
+
const marker = path.join(lockPath, name);
|
|
459
|
+
let info;
|
|
460
|
+
try { info = fs.lstatSync(marker); }
|
|
461
|
+
catch (err) { if (err && err.code === "ENOENT") continue; throw err; }
|
|
462
|
+
if (!info.isFile()) throw qoderLockArtifactError("choosing marker", marker);
|
|
463
|
+
markers.push(marker);
|
|
464
|
+
}
|
|
465
|
+
return markers;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function removeDeadQoderChoosingMarkers(markers) {
|
|
469
|
+
for (const marker of markers) {
|
|
470
|
+
const match = /^choosing-([1-9]\d*)-([0-9a-f]{32})$/.exec(path.basename(marker));
|
|
471
|
+
if (!match) throw new Error(`Qoder update choosing marker ${marker} is malformed; remove it manually only after confirming no updater is running.`);
|
|
472
|
+
const pid = Number.parseInt(match[1], 10);
|
|
473
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > QODER_LOCK_MAX_PID) throw new Error(`Qoder update choosing marker ${marker} has an unsupported owner; remove it manually only after confirming no updater is running.`);
|
|
474
|
+
if (qoderOwnerState(pid) === "dead") fs.rmSync(marker, { force: true });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function validateQoderLockTickets(tickets) {
|
|
479
|
+
for (const ticket of tickets) {
|
|
480
|
+
const match = /^(\d{20})-([1-9]\d*)-([0-9a-f]{32})\.ticket$/.exec(path.basename(ticket));
|
|
481
|
+
const pid = match ? Number.parseInt(match[2], 10) : 0;
|
|
482
|
+
if (!match) {
|
|
483
|
+
throw new Error(`Qoder update ticket ${ticket} is corrupt; remove it manually only after confirming no updater is running.`);
|
|
484
|
+
}
|
|
485
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > QODER_LOCK_MAX_PID) {
|
|
486
|
+
throw new Error(`Qoder update ticket ${ticket} has an unsupported owner; remove it manually only after confirming no updater is running.`);
|
|
487
|
+
}
|
|
488
|
+
const state = qoderOwnerState(pid);
|
|
489
|
+
if (state === "dead") {
|
|
490
|
+
let age = Infinity;
|
|
491
|
+
try {
|
|
492
|
+
const info = fs.lstatSync(ticket);
|
|
493
|
+
if (!info.isFile()) throw qoderLockArtifactError("ticket", ticket);
|
|
494
|
+
age = Date.now() - info.mtimeMs;
|
|
495
|
+
}
|
|
496
|
+
catch (err) { if (err && err.code === "ENOENT") continue; throw err; }
|
|
497
|
+
// Avoid mistaking the normal exit-before-finally micro-window for a
|
|
498
|
+
// crashed owner. The ticket still blocks entry throughout this grace.
|
|
499
|
+
if (age < 1000) continue;
|
|
500
|
+
}
|
|
501
|
+
if (state !== "alive") {
|
|
502
|
+
throw new Error(`Qoder update ticket ${ticket} has ${state} owner pid ${pid}; refusing automatic recovery while an updater may survive; remove it manually only after confirming no updater is running.`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function withQoderUpdateLock(executable, fn) {
|
|
508
|
+
const lockPaths = qoderUpdateLockPaths(executable);
|
|
509
|
+
fs.mkdirSync(path.dirname(lockPaths[0]), { recursive: true, mode: 0o700 });
|
|
510
|
+
const deadline = Date.now() + 15 * 60 * 1000;
|
|
511
|
+
const owner = { pid: process.pid, token: crypto.randomBytes(16).toString("hex") };
|
|
512
|
+
const acquired = [];
|
|
513
|
+
let failure = null;
|
|
514
|
+
try {
|
|
515
|
+
for (const lockPath of lockPaths) {
|
|
516
|
+
fs.mkdirSync(lockPath, { recursive: true, mode: 0o700 });
|
|
517
|
+
const plannedChoosing = path.join(lockPath, `choosing-${owner.pid}-${owner.token}`);
|
|
518
|
+
acquired.push(plannedChoosing);
|
|
519
|
+
const choosing = writeQoderChoosingMarker(lockPath, owner);
|
|
520
|
+
let snapshot = qoderLockTickets(lockPath);
|
|
521
|
+
validateQoderLockTickets(snapshot.tickets);
|
|
522
|
+
if (snapshot.maximum >= 18446744073709551615n) {
|
|
523
|
+
throw new Error(`Qoder update lock sequence exhausted at ${lockPath}`);
|
|
524
|
+
}
|
|
525
|
+
const plannedTicketName = `${String(snapshot.maximum + 1n).padStart(20, "0")}-${owner.pid}-${owner.token}.ticket`;
|
|
526
|
+
acquired.push(path.join(lockPath, `.${plannedTicketName}.tmp`));
|
|
527
|
+
const ticket = writeQoderLockTicket(lockPath, snapshot.maximum + 1n, owner);
|
|
528
|
+
acquired.push(ticket);
|
|
529
|
+
fs.rmSync(choosing, { force: true });
|
|
530
|
+
for (;;) {
|
|
531
|
+
let markers = qoderChoosingMarkers(lockPath);
|
|
532
|
+
removeDeadQoderChoosingMarkers(markers);
|
|
533
|
+
markers = qoderChoosingMarkers(lockPath);
|
|
534
|
+
snapshot = qoderLockTickets(lockPath);
|
|
535
|
+
validateQoderLockTickets(snapshot.tickets);
|
|
536
|
+
snapshot = qoderLockTickets(lockPath);
|
|
537
|
+
if (markers.length === 0 && snapshot.tickets[0] === ticket) break;
|
|
538
|
+
if (Date.now() >= deadline) {
|
|
539
|
+
error(`Timed out waiting for another AutoWonder process to finish updating ${executable} (lock queue ${lockPath}).`);
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return fn();
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
failure = err;
|
|
549
|
+
throw err;
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
const cleanupErrors = [];
|
|
553
|
+
for (const ticket of acquired.reverse()) {
|
|
554
|
+
try { fs.rmSync(ticket, { force: true }); } catch (err) { cleanupErrors.push(err); }
|
|
555
|
+
}
|
|
556
|
+
if (cleanupErrors.length) {
|
|
557
|
+
if (failure) failure.cleanupErrors = cleanupErrors;
|
|
558
|
+
else throw new AggregateError(cleanupErrors, "failed to clean Qoder update lock artifacts");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function ensureMinimumQoderVersion(profile, provider, executable) {
|
|
564
|
+
const result = withQoderUpdateLock(executable, () => ensureMinimumQoderVersionLocked(profile, provider, executable));
|
|
565
|
+
return result || { ok: false, version: "unknown", executable };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function ensureMinimumQoderVersionLocked(profile, provider, executable) {
|
|
569
|
+
let version = qoderBinaryVersion(executable);
|
|
570
|
+
if (qoderVersionMeetsMinimum(version)) return { ok: true, version };
|
|
571
|
+
|
|
572
|
+
log(`${profile.displayName} ${version} is older than required version 1.1.50 (or could not be parsed). Updating the current installation to latest...`);
|
|
573
|
+
const update = spawnQoderSync(executable, ["update"], { stdio: "inherit", timeout: 5 * 60 * 1000, treeTimeout: true });
|
|
574
|
+
if (update.status !== 0) {
|
|
575
|
+
const managedDir = qoderPaths(profile).managedDir;
|
|
576
|
+
if (executable.startsWith(`${managedDir}${path.sep}`)) {
|
|
577
|
+
const reinstalled = installQoderCLI(provider, { lockHeld: true });
|
|
578
|
+
if (reinstalled) {
|
|
579
|
+
executable = reinstalled;
|
|
580
|
+
version = qoderBinaryVersion(executable);
|
|
581
|
+
if (qoderVersionMeetsMinimum(version)) return { ok: true, version, executable };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const detail = update.error?.message || (update.signal ? `signal ${update.signal}` : `exit status ${update.status}`);
|
|
585
|
+
error(`${profile.displayName} requires version 1.1.50 or newer, but updating ${executable} failed: ${detail}`);
|
|
586
|
+
return { ok: false, version, executable };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
version = qoderBinaryVersion(executable);
|
|
590
|
+
if (!qoderVersionMeetsMinimum(version)) {
|
|
591
|
+
error(`${profile.displayName} requires version 1.1.50 or newer, but ${executable} reports ${version} after updating to latest.`);
|
|
592
|
+
return { ok: false, version, executable };
|
|
593
|
+
}
|
|
594
|
+
log(`${profile.displayName} updated in place to ${version}.`);
|
|
595
|
+
return { ok: true, version, executable };
|
|
269
596
|
}
|
|
270
597
|
|
|
271
598
|
// A missing or unreadable record means "no history", never a bootstrap failure.
|
|
@@ -348,6 +675,7 @@ function probeQoderAuth(profile, executable, configRoot, options = {}) {
|
|
|
348
675
|
encoding: "utf8",
|
|
349
676
|
stdio: ["ignore", "pipe", "pipe"],
|
|
350
677
|
timeout: 20000,
|
|
678
|
+
treeTimeout: true,
|
|
351
679
|
env: { ...process.env, [profile.configDirEnv]: configRoot },
|
|
352
680
|
});
|
|
353
681
|
const probe = {
|
|
@@ -556,6 +884,10 @@ async function loginThenWait(profile, executable, configRoot, options) {
|
|
|
556
884
|
function finishQoderReady(profile, provider, executable, version, configRoot, rootSource) {
|
|
557
885
|
writeQoderState(profile, provider, { resolvedPath: executable, version, configRoot });
|
|
558
886
|
process.env[profile.envVar] = executable;
|
|
887
|
+
process.env[QODER_INTEGRATION_ID_ENV] = QODER_INTEGRATION_ID;
|
|
888
|
+
for (const key of Object.keys(process.env)) {
|
|
889
|
+
if (key.toLowerCase() === LEGACY_QODER_HOST_SERVICE_NAME_ENV.toLowerCase()) delete process.env[key];
|
|
890
|
+
}
|
|
559
891
|
const executableDir = path.dirname(executable);
|
|
560
892
|
const pathEntries = (process.env.PATH || "").split(path.delimiter);
|
|
561
893
|
if (!pathEntries.includes(executableDir)) {
|
|
@@ -566,7 +898,6 @@ function finishQoderReady(profile, provider, executable, version, configRoot, ro
|
|
|
566
898
|
}
|
|
567
899
|
|
|
568
900
|
async function ensureQoderReady(provider, options = {}) {
|
|
569
|
-
if (process.env.AUTOWONDER_SKIP_QODER_BOOTSTRAP === "1") return true;
|
|
570
901
|
const profile = QODER_FAMILY_PROFILES[provider];
|
|
571
902
|
const inspectOptions = { debug: Boolean(options.debug) };
|
|
572
903
|
|
|
@@ -585,11 +916,14 @@ async function ensureQoderReady(provider, options = {}) {
|
|
|
585
916
|
let refreshed = false;
|
|
586
917
|
if (!executable) {
|
|
587
918
|
reportQoderNotFound(provider);
|
|
588
|
-
executable = installQoderCLI(provider);
|
|
919
|
+
executable = installQoderCLI(provider, { skipIfPresent: true });
|
|
589
920
|
refreshed = true;
|
|
590
921
|
}
|
|
591
922
|
if (!executable) return false;
|
|
592
|
-
|
|
923
|
+
const versionGate = ensureMinimumQoderVersion(profile, provider, executable);
|
|
924
|
+
if (!versionGate.ok) return false;
|
|
925
|
+
executable = versionGate.executable || executable;
|
|
926
|
+
let version = versionGate.version;
|
|
593
927
|
|
|
594
928
|
const paths = qoderPaths(profile);
|
|
595
929
|
const selection = selectQoderConfigRoot(profile, executable, inspectOptions);
|
|
@@ -626,7 +960,10 @@ async function ensureQoderReady(provider, options = {}) {
|
|
|
626
960
|
const reinstalled = installQoderCLI(provider);
|
|
627
961
|
if (reinstalled) {
|
|
628
962
|
executable = reinstalled;
|
|
629
|
-
|
|
963
|
+
const reinstalledGate = ensureMinimumQoderVersion(profile, provider, executable);
|
|
964
|
+
if (!reinstalledGate.ok) return false;
|
|
965
|
+
executable = reinstalledGate.executable || executable;
|
|
966
|
+
version = reinstalledGate.version;
|
|
630
967
|
verdict = inspectQoderHealth(profile, executable, configRoot, inspectOptions);
|
|
631
968
|
if (verdict.state === "ready") {
|
|
632
969
|
return finishQoderReady(profile, provider, executable, version, configRoot, rootSource);
|
|
@@ -665,7 +1002,10 @@ async function ensureQoderReady(provider, options = {}) {
|
|
|
665
1002
|
const reinstalled = installQoderCLI(provider);
|
|
666
1003
|
if (reinstalled) {
|
|
667
1004
|
executable = reinstalled;
|
|
668
|
-
|
|
1005
|
+
const reinstalledGate = ensureMinimumQoderVersion(profile, provider, executable);
|
|
1006
|
+
if (!reinstalledGate.ok) return false;
|
|
1007
|
+
executable = reinstalledGate.executable || executable;
|
|
1008
|
+
version = reinstalledGate.version;
|
|
669
1009
|
}
|
|
670
1010
|
}
|
|
671
1011
|
log(`${profile.displayName} credentials under ${configRoot} are not usable (${verdict.reason}).`);
|
|
@@ -692,12 +1032,17 @@ async function ensureQoderReady(provider, options = {}) {
|
|
|
692
1032
|
|
|
693
1033
|
module.exports = {
|
|
694
1034
|
QODER_FAMILY_PROFILES,
|
|
1035
|
+
ensureMinimumQoderVersion,
|
|
695
1036
|
ensureQoderReady,
|
|
696
1037
|
findInInstallDirs,
|
|
697
1038
|
findQoderExecutable,
|
|
698
1039
|
inspectQoderHealth,
|
|
1040
|
+
installQoderCLIUnlocked,
|
|
699
1041
|
isQoderFamilyProvider,
|
|
700
1042
|
qoderBinaryVersion,
|
|
1043
|
+
qoderChildEnv,
|
|
1044
|
+
qoderUpdateLockPaths,
|
|
1045
|
+
qoderVersionMeetsMinimum,
|
|
701
1046
|
qoderPaths,
|
|
702
1047
|
qoderSearchSummary,
|
|
703
1048
|
quoteWindowsArg,
|
|
@@ -707,5 +1052,7 @@ module.exports = {
|
|
|
707
1052
|
selectQoderConfigRoot,
|
|
708
1053
|
spawnQoderSync,
|
|
709
1054
|
waitForQoderLogin,
|
|
1055
|
+
windowsQoderShimInvocation,
|
|
1056
|
+
withQoderUpdateLock,
|
|
710
1057
|
writeQoderState,
|
|
711
1058
|
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)][int]$TimeoutMs,
|
|
3
|
+
[Parameter(Mandatory = $true)][string]$Executable,
|
|
4
|
+
[Parameter(Mandatory = $true)][string]$ArgsBase64
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = "Stop"
|
|
8
|
+
$argumentJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($ArgsBase64))
|
|
9
|
+
[string[]]$childArguments = @(ConvertFrom-Json $argumentJson)
|
|
10
|
+
|
|
11
|
+
if (-not ("AutoWonder.WindowsJobRunner" -as [type])) {
|
|
12
|
+
Add-Type -TypeDefinition @'
|
|
13
|
+
using System;
|
|
14
|
+
using System.ComponentModel;
|
|
15
|
+
using System.Runtime.InteropServices;
|
|
16
|
+
using System.Text;
|
|
17
|
+
|
|
18
|
+
namespace AutoWonder {
|
|
19
|
+
public static class WindowsJobRunner {
|
|
20
|
+
const uint CREATE_SUSPENDED = 0x00000004;
|
|
21
|
+
const uint STARTF_USESTDHANDLES = 0x00000100;
|
|
22
|
+
const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
|
23
|
+
const uint WAIT_OBJECT_0 = 0;
|
|
24
|
+
const uint WAIT_TIMEOUT = 258;
|
|
25
|
+
const int JobObjectExtendedLimitInformation = 9;
|
|
26
|
+
|
|
27
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
28
|
+
struct STARTUPINFO {
|
|
29
|
+
public int cb; public string lpReserved; public string lpDesktop; public string lpTitle;
|
|
30
|
+
public int dwX; public int dwY; public int dwXSize; public int dwYSize;
|
|
31
|
+
public int dwXCountChars; public int dwYCountChars; public int dwFillAttribute;
|
|
32
|
+
public uint dwFlags; public short wShowWindow; public short cbReserved2;
|
|
33
|
+
public IntPtr lpReserved2; public IntPtr hStdInput; public IntPtr hStdOutput; public IntPtr hStdError;
|
|
34
|
+
}
|
|
35
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
36
|
+
struct PROCESS_INFORMATION { public IntPtr hProcess; public IntPtr hThread; public uint dwProcessId; public uint dwThreadId; }
|
|
37
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
38
|
+
struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
|
39
|
+
public long PerProcessUserTimeLimit; public long PerJobUserTimeLimit; public uint LimitFlags;
|
|
40
|
+
public UIntPtr MinimumWorkingSetSize; public UIntPtr MaximumWorkingSetSize;
|
|
41
|
+
public uint ActiveProcessLimit; public UIntPtr Affinity; public uint PriorityClass; public uint SchedulingClass;
|
|
42
|
+
}
|
|
43
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
44
|
+
struct IO_COUNTERS {
|
|
45
|
+
public ulong ReadOperationCount; public ulong WriteOperationCount; public ulong OtherOperationCount;
|
|
46
|
+
public ulong ReadTransferCount; public ulong WriteTransferCount; public ulong OtherTransferCount;
|
|
47
|
+
}
|
|
48
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
49
|
+
struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
|
50
|
+
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; public IO_COUNTERS IoInfo;
|
|
51
|
+
public UIntPtr ProcessMemoryLimit; public UIntPtr JobMemoryLimit; public UIntPtr PeakProcessMemoryUsed; public UIntPtr PeakJobMemoryUsed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
55
|
+
static extern bool CreateProcessW(string app, StringBuilder commandLine, IntPtr processAttributes,
|
|
56
|
+
IntPtr threadAttributes, bool inheritHandles, uint flags, IntPtr environment, string cwd,
|
|
57
|
+
ref STARTUPINFO startupInfo, out PROCESS_INFORMATION processInformation);
|
|
58
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
|
|
59
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length);
|
|
60
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
|
61
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern uint ResumeThread(IntPtr thread);
|
|
62
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
|
63
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
|
|
64
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern bool TerminateProcess(IntPtr process, uint exitCode);
|
|
65
|
+
[DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int handle);
|
|
66
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
|
67
|
+
|
|
68
|
+
static string Quote(string value) {
|
|
69
|
+
if (value.Length > 0 && value.IndexOfAny(new[] {' ', '\t', '\n', '\v', '"'}) < 0) return value;
|
|
70
|
+
var result = new StringBuilder("\""); int slashes = 0;
|
|
71
|
+
foreach (char ch in value) {
|
|
72
|
+
if (ch == '\\') { slashes++; continue; }
|
|
73
|
+
if (ch == '"') { result.Append('\\', slashes * 2 + 1).Append('"'); slashes = 0; continue; }
|
|
74
|
+
result.Append('\\', slashes).Append(ch); slashes = 0;
|
|
75
|
+
}
|
|
76
|
+
result.Append('\\', slashes * 2).Append('"'); return result.ToString();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public static int Run(string executable, string[] args, int timeoutMs) {
|
|
80
|
+
IntPtr job = IntPtr.Zero; PROCESS_INFORMATION process = new PROCESS_INFORMATION();
|
|
81
|
+
try {
|
|
82
|
+
job = CreateJobObject(IntPtr.Zero, null);
|
|
83
|
+
if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject");
|
|
84
|
+
var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
|
85
|
+
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
86
|
+
int size = Marshal.SizeOf(limits); IntPtr limitsPtr = Marshal.AllocHGlobal(size);
|
|
87
|
+
try {
|
|
88
|
+
Marshal.StructureToPtr(limits, limitsPtr, false);
|
|
89
|
+
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, limitsPtr, (uint)size))
|
|
90
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject");
|
|
91
|
+
} finally { Marshal.FreeHGlobal(limitsPtr); }
|
|
92
|
+
|
|
93
|
+
var command = new StringBuilder(Quote(executable));
|
|
94
|
+
foreach (string arg in args ?? new string[0]) command.Append(' ').Append(Quote(arg ?? ""));
|
|
95
|
+
var startup = new STARTUPINFO(); startup.cb = Marshal.SizeOf(startup);
|
|
96
|
+
startup.dwFlags = STARTF_USESTDHANDLES;
|
|
97
|
+
startup.hStdInput = GetStdHandle(-10); startup.hStdOutput = GetStdHandle(-11); startup.hStdError = GetStdHandle(-12);
|
|
98
|
+
if (!CreateProcessW(executable, command, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED,
|
|
99
|
+
IntPtr.Zero, null, ref startup, out process))
|
|
100
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcess");
|
|
101
|
+
if (!AssignProcessToJobObject(job, process.hProcess)) {
|
|
102
|
+
TerminateProcess(process.hProcess, 125); throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject");
|
|
103
|
+
}
|
|
104
|
+
if (ResumeThread(process.hThread) == UInt32.MaxValue) {
|
|
105
|
+
TerminateProcess(process.hProcess, 125); throw new Win32Exception(Marshal.GetLastWin32Error(), "ResumeThread");
|
|
106
|
+
}
|
|
107
|
+
uint wait = WaitForSingleObject(process.hProcess, (uint)Math.Max(1, timeoutMs));
|
|
108
|
+
if (wait == WAIT_TIMEOUT) { CloseHandle(job); job = IntPtr.Zero; WaitForSingleObject(process.hProcess, 10000); return 124; }
|
|
109
|
+
if (wait != WAIT_OBJECT_0) throw new Win32Exception(Marshal.GetLastWin32Error(), "WaitForSingleObject");
|
|
110
|
+
uint exitCode; if (!GetExitCodeProcess(process.hProcess, out exitCode))
|
|
111
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(), "GetExitCodeProcess");
|
|
112
|
+
return unchecked((int)exitCode);
|
|
113
|
+
} finally {
|
|
114
|
+
if (process.hThread != IntPtr.Zero) CloseHandle(process.hThread);
|
|
115
|
+
if (process.hProcess != IntPtr.Zero) CloseHandle(process.hProcess);
|
|
116
|
+
// Closing the last handle synchronously terminates every surviving descendant.
|
|
117
|
+
if (job != IntPtr.Zero) CloseHandle(job);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
'@
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try { exit [AutoWonder.WindowsJobRunner]::Run($Executable, $childArguments, $TimeoutMs) }
|
|
126
|
+
catch { [Console]::Error.WriteLine($_.Exception.ToString()); exit 125 }
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|