fix(cli): isolate standalone version probe startup environment

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
gary.gqandmultica-agent committed 2026-10-15 03:43:18 +08:00
1 parent c57e2cf8b4
commit ce67edaba5
5 files changed
+335 -111

No files matched your search

+65 -50
View File
@@ -9,7 +9,7 @@ Status: implemented in MYWO-8 S1 (this change). Audience: S2 (Bun PTY), S3
`process.execPath` conflates two distinct identities:
- the **app launcher** — how a subprocess reaches *this build* of the CLI;
- the **app launcher** — how a subprocess reaches _this build_ of the CLI;
- the **JS interpreter** — the binary that runs an arbitrary JavaScript file
or `-e` snippet as a child.
@@ -30,35 +30,35 @@ Classified by which identity the site actually needs.
### App launcher identity (how to re-invoke this build)
| Site | Mechanism | Notes |
| --- | --- | --- |
| `scripts/cli-entry.js` | stamps `QWEN_CODE_CLI` (shim path when `QWEN_CODE_LAUNCHER_PATH` set, else the entry file); captures + deletes `QWEN_CODE_LAUNCHER_PATH` | the launcher source of truth |
| `packages/core/src/services/shellContextEnv.ts` | propagates `QWEN_CODE_CLI` into shell subprocess env; blanks a set-but-unusable script entry | consumers use `"${QWEN_CODE_CLI:-qwen}"` |
| `packages/cli/src/cli.ts` (`getShellContextEnvVars` wiring) | same env propagation for every shell the CLI spawns | |
| `packages/cli/src/serve/routes/daemon-update.ts` | reads `QWEN_CODE_CLI` as the bootstrap for update relaunch | |
| `packages/cli/src/commands/review/**` | `"${QWEN_CODE_CLI:-qwen}" review …` self-invocation in agent prompts | launcher identity, pinned by tests |
| `scripts/cli-entry.js` `relaunchAfterUpdate()` | `findLauncher()` prefers `QWEN_CODE_LAUNCHER_PATH`, then PATH scan scored by install prefix | update relaunch |
| `packages/cli/src/utils/relaunch.ts` (`relaunchAppInChildProcess`) | supervised relaunch: `spawn(process.execPath, [execArgv, argv[1], args], ipc)`; `process.execve` variant when available | same-interpreter fallback of the launcher |
| `packages/cli/src/utils/processUtils.ts` (`relaunchApp`) | `execve(process.execPath, [execPath, execArgv, argv…])` | same-interpreter fallback |
| `packages/core/src/agents/arena/ArenaManager.ts` | `command: process.execPath, args: [resolve(argv[1]), …]` per arena agent | app re-invocation in a worktree; must stay launcher-shaped |
| `packages/cli/src/serve/managed-runtime-session-worker.ts` (`currentCliWorkerLaunch`) | `command: process.execPath, args: [execArgv, QWEN_CLI_ENTRY \|\| argv[1], 'managed-runtime-worker']` | app re-invocation |
| `packages/cli/src/commands/review/run.ts` (agent runner spawn) | `process.execPath` + entry | app re-invocation |
| Site | Mechanism | Notes |
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `scripts/cli-entry.js` | stamps `QWEN_CODE_CLI` (shim path when `QWEN_CODE_LAUNCHER_PATH` set, else the entry file); captures + deletes `QWEN_CODE_LAUNCHER_PATH` | the launcher source of truth |
| `packages/core/src/services/shellContextEnv.ts` | propagates `QWEN_CODE_CLI` into shell subprocess env; blanks a set-but-unusable script entry | consumers use `"${QWEN_CODE_CLI:-qwen}"` |
| `packages/cli/src/cli.ts` (`getShellContextEnvVars` wiring) | same env propagation for every shell the CLI spawns | |
| `packages/cli/src/serve/routes/daemon-update.ts` | reads `QWEN_CODE_CLI` as the bootstrap for update relaunch | |
| `packages/cli/src/commands/review/**` | `"${QWEN_CODE_CLI:-qwen}" review …` self-invocation in agent prompts | launcher identity, pinned by tests |
| `scripts/cli-entry.js` `relaunchAfterUpdate()` | `findLauncher()` prefers `QWEN_CODE_LAUNCHER_PATH`, then PATH scan scored by install prefix | update relaunch |
| `packages/cli/src/utils/relaunch.ts` (`relaunchAppInChildProcess`) | supervised relaunch: `spawn(process.execPath, [execArgv, argv[1], args], ipc)`; `process.execve` variant when available | same-interpreter fallback of the launcher |
| `packages/cli/src/utils/processUtils.ts` (`relaunchApp`) | `execve(process.execPath, [execPath, execArgv, argv…])` | same-interpreter fallback |
| `packages/core/src/agents/arena/ArenaManager.ts` | `command: process.execPath, args: [resolve(argv[1]), …]` per arena agent | app re-invocation in a worktree; must stay launcher-shaped |
| `packages/cli/src/serve/managed-runtime-session-worker.ts` (`currentCliWorkerLaunch`) | `command: process.execPath, args: [execArgv, QWEN_CLI_ENTRY \|\| argv[1], 'managed-runtime-worker']` | app re-invocation |
| `packages/cli/src/commands/review/run.ts` (agent runner spawn) | `process.execPath` + entry | app re-invocation |
### JS interpreter identity (how to run an arbitrary JS child)
| Site | Use | Wired to `getJsInterpreterCommand` in this change |
| --- | --- | --- |
| `packages/core/src/code-mode/host-client.ts` (`hostCommand`) | code-mode host child (`host.js` / bundled `codeModeHost.js`; dev: `--import tsx host.ts`) | yes |
| `packages/core/src/sandbox/file-worker-client.ts` | sandbox file-worker child (`sandboxAsset('file-worker')`) | yes |
| `packages/core/src/hooks/hookRunner.ts` (surviving-hook supervisor) | `spawn(--input-type=commonjs --eval SUPERVISOR …)`, detached | yes |
| `packages/core/src/hooks/hook-command-cgroup.ts` (`launch`) | cgroup-v2 launcher child (`--input-type=commonjs --eval LAUNCHER …`) running the hook under its delegated unit | not yet (S3) — Linux only (cgroup v2 platform guard) |
| `packages/core/src/sandbox/bwrap-execution.ts` (`executeBwrap`) | stages the interpreter (`realpathSync(process.execPath)`) into the bubblewrap allowlist; the relay spawns the JS relay under it | not yet (S3) — Linux only (bubblewrap) |
| `packages/core/src/sandbox/landlock-execution.ts` (`executeLandlock`) | stages the interpreter into the landlock allowlist; the relay spawns the JS relay under it | not yet (S3) — Linux only (landlock LSM) |
| `packages/core/src/utils/getPty.ts` (`loadPty`) | Bun detection for the PTY gate (PTY disabled under Bun today — **S2 replaces this branch with a real backend**) | detection via `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts` (`bundledMem0Server`) | bundled mem0 MCP child; identity check `server.command !== process.execPath` | not yet; must migrate with its identity check together (S3) |
| `packages/core/src/agents/arena/ArenaManager.ts` | (also) worker-side in-process runtime | launcher-shaped, see above |
| `packages/cli/src/serve/pem-certificate-blocks.ts` | helper child spawn | not yet (S3 sweep) |
| `packages/cli/src/commands/review/test-efficacy.ts` | spawns vitest via `process.execPath` (dev-tooling path) | intentionally not; host-tool identity |
| Site | Use | Wired to `getJsInterpreterCommand` in this change |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `packages/core/src/code-mode/host-client.ts` (`hostCommand`) | code-mode host child (`host.js` / bundled `codeModeHost.js`; dev: `--import tsx host.ts`) | yes |
| `packages/core/src/sandbox/file-worker-client.ts` | sandbox file-worker child (`sandboxAsset('file-worker')`) | yes |
| `packages/core/src/hooks/hookRunner.ts` (surviving-hook supervisor) | `spawn(--input-type=commonjs --eval SUPERVISOR …)`, detached | yes |
| `packages/core/src/hooks/hook-command-cgroup.ts` (`launch`) | cgroup-v2 launcher child (`--input-type=commonjs --eval LAUNCHER …`) running the hook under its delegated unit | not yet (S3) — Linux only (cgroup v2 platform guard) |
| `packages/core/src/sandbox/bwrap-execution.ts` (`executeBwrap`) | stages the interpreter (`realpathSync(process.execPath)`) into the bubblewrap allowlist; the relay spawns the JS relay under it | not yet (S3) — Linux only (bubblewrap) |
| `packages/core/src/sandbox/landlock-execution.ts` (`executeLandlock`) | stages the interpreter into the landlock allowlist; the relay spawns the JS relay under it | not yet (S3) — Linux only (landlock LSM) |
| `packages/core/src/utils/getPty.ts` (`loadPty`) | Bun detection for the PTY gate (PTY disabled under Bun today — **S2 replaces this branch with a real backend**) | detection via `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts` (`bundledMem0Server`) | bundled mem0 MCP child; identity check `server.command !== process.execPath` | not yet; must migrate with its identity check together (S3) |
| `packages/core/src/agents/arena/ArenaManager.ts` | (also) worker-side in-process runtime | launcher-shaped, see above |
| `packages/cli/src/serve/pem-certificate-blocks.ts` | helper child spawn | not yet (S3 sweep) |
| `packages/cli/src/commands/review/test-efficacy.ts` | spawns vitest via `process.execPath` (dev-tooling path) | intentionally not; host-tool identity |
Rows marked `not yet` are owned by the **S3 migration sweep** (V8/GC/TLS/env
compatibility stage), which uses this table as its checklist. The
@@ -69,13 +69,13 @@ platform.
### Update / install identity (must stay host-Node)
| Site | Notes |
| --- | --- |
| `packages/cli/src/utils/managed-npm-update.ts` | runs npm's `cli.js` via `process.execPath`; npm **must** run under a Node identity, never the private bundled Bun — keep host Node resolution here |
| `packages/cli/src/ui/handleAutoUpdate.ts` | picks the update command; Node-identity path |
| `packages/cli/src/serve/daemon-update-restart.ts` | already disabled under Bun (`process.versions['bun']`) and execve-feature-detected |
| `packages/cli/src/utils/installationInfo.ts` | reports `process.execPath` as the node path (informational) |
| `scripts/build-standalone-release.js` / `create-standalone-package.js` | pin Bun 1.3.14, five targets, `manifest.json` (`runtime`, version); layout `bun/bin/bun`, `node/bin/node` |
| Site | Notes |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/cli/src/utils/managed-npm-update.ts` | runs npm's `cli.js` via `process.execPath`; npm **must** run under a Node identity, never the private bundled Bun — keep host Node resolution here |
| `packages/cli/src/ui/handleAutoUpdate.ts` | picks the update command; Node-identity path |
| `packages/cli/src/serve/daemon-update-restart.ts` | already disabled under Bun (`process.versions['bun']`) and execve-feature-detected |
| `packages/cli/src/utils/installationInfo.ts` | reports `process.execPath` as the node path (informational) |
| `scripts/build-standalone-release.js` / `create-standalone-package.js` | pin Bun 1.3.14, five targets, `manifest.json` (`runtime`, version); layout `bun/bin/bun`, `node/bin/node` |
### SDK consumers
@@ -93,7 +93,7 @@ source installs do not have.
`getShellContextEnvVars` propagates it. Self-invocation uses
`"${QWEN_CODE_CLI:-qwen}"` in shells, or the stamped value for direct
spawns.
- Re-invoking the app *inside* the same process tree (relaunch, managed
- Re-invoking the app _inside_ the same process tree (relaunch, managed
workers, arena agents) keeps the same-interpreter fallback
`process.execPath + process.argv[1]`, because the entry is a JS file in
every shipped layout. A compiled flavor must publish its launcher through
@@ -135,18 +135,18 @@ through `QWEN_CODE_CLI` / the entry fallback.
## Bun compatibility facts (probed this change)
Probed with Bun 1.4.0 local vs Node 24.19.0 (same fixture, see
`packages/core/src/utils/runtime-boundary.node-bun.test.ts`). The *shipped*
`packages/core/src/utils/runtime-boundary.node-bun.test.ts`). The _shipped_
Bun 1.3.14 pin is verified by the packaging acceptance, not here.
| Behavior | Node 24.19.0 | Bun 1.4.0 |
| --- | --- | --- |
| `--expose-gc script.js` → `typeof gc === 'function'` | yes | yes (`--expose-gc` accepted) |
| `--input-type=commonjs --eval '…'` | yes | yes |
| Node IPC channel (`spawn stdio:[…,'ipc']`, `process.send` / `on('message')`) | yes | yes, round-trip OK |
| SIGTERM handler → graceful `exit(0)` | yes | yes |
| exit-code propagation via `process.exit(n)` | yes | yes |
| `process.execve` | yes | absent — code must feature-detect (`relaunch.ts`, `processUtils.ts`, `daemon-update-restart.ts` already do) |
| PTY (`@lydell/node-pty`) | yes | **disabled in `getPty`** — S2 blocker |
| Behavior | Node 24.19.0 | Bun 1.4.0 |
| ---------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
| `--expose-gc script.js` → `typeof gc === 'function'` | yes | yes (`--expose-gc` accepted) |
| `--input-type=commonjs --eval '…'` | yes | yes |
| Node IPC channel (`spawn stdio:[…,'ipc']`, `process.send` / `on('message')`) | yes | yes, round-trip OK |
| SIGTERM handler → graceful `exit(0)` | yes | yes |
| exit-code propagation via `process.exit(n)` | yes | yes |
| `process.execve` | yes | absent — code must feature-detect (`relaunch.ts`, `processUtils.ts`, `daemon-update-restart.ts` already do) |
| PTY (`@lydell/node-pty`) | yes | **disabled in `getPty`** — S2 blocker |
## Validation plan
@@ -180,7 +180,8 @@ checksum verification, identity/version rejection, activation and rollback;
Both smoke processes use a single private empty directory, with removal attempted in `finally`,
to avoid executing project or archive `bunfig.toml` preloads. Smoke environment
copies remove NODE_OPTIONS/BUN_OPTIONS while retaining other variables. Installed
copies remove NODE_OPTIONS/BUN_OPTIONS, CLI_VERSION, and QWEN_CODE_RELAUNCH_ARGS
without changing the parent environment or ordinary business variables. Installed
manifest null, scalars, arrays and invalid runtime declarations are rejected
before download or lock allocation; an object without runtime retains the legacy
Node fallback. Allocation assertions require a successful positive control; on
@@ -200,11 +201,21 @@ using the existing semver rules, including `v` normalization and prereleases.
Legal older/newer versions must be rejected before replacement in both direct
and prepared updates. Version-only preload controls match `--version` for both
CLI entrypoints and verify that the identity probe does not execute the preload.
The regression also uses the actual entry wrapper with polluted ambient versions:
correct packages still update and roll back, while older/newer packages remain
rejected in both modes under both hosts and payload interpreters. Node runs this
wrapper as a regression fixture; its production bundle has a compiled version.
Test-only callback observation reports probe stage, elapsed time, signal and
killed status where the host supports builtin binding observation. This does not
change the 10-second budget or explain the recorded first-run timeouts; their
root cause remains unresolved.
Cleanup failures retain the original smoke outcome and may leave a temporary
directory; swallowing a removal error is not evidence that it was removed.
Remote CI execution is still pending. The frozen PR #14187 head `d14b2ad1`
has no `ci.yml` run: its stacked base is outside that workflow's branch filter.
Remote CI execution is still pending. Neither the frozen PR #14210 head
`c57e2cf8` nor the preceding PR #14187 head `d14b2ad1` has a `ci.yml` run.
Both PRs currently target stacked branches outside the workflow's
`pull_request` target-branch filter.
The ECS lane reuses Node 22 and its access to nodejs.org has not been measured;
neither that installation nor an unverified pool image satisfies the fixed
Node 24.19.0/Bun 1.3.14 contract. Local script results do not establish a remote
@@ -213,12 +224,16 @@ lane result. Branch filters, the PR base, and manual dispatch remain unchanged.
For a pool with a trusted copy of the Node distribution, CI mode accepts
`QWEN_TEST_NODE_BASE_URL`: an HTTPS directory ending in `/` containing the
fixed archive and `SHASUMS256.txt`. The default remains the official directory;
the checksum and hard version assertions remain mandatory. Alternatively,
the checksum and hard version assertions remain mandatory. This override
only supplies Node. Bun still needs access to the npm registry unless a
verified Bun 1.3.14 executable is supplied as `PINNED_BUN`. An isolated pool
using the Node distribution override must also supply `PINNED_BUN`. Alternatively,
provide real `PINNED_NODE`/`PINNED_BUN` executables; the driver resolves both to
absolute paths before changing cwd. No mirror or preinstalled fixed tools have
been established for ECS. The minimal pending workflow change is to pass the
verified pool resource into the existing script-test step, for example
`QWEN_TEST_NODE_BASE_URL: ${{ vars.QWEN_TEST_NODE_BASE_URL }}`, or both executable
`QWEN_TEST_NODE_BASE_URL: ${{ vars.QWEN_TEST_NODE_BASE_URL }}` together with
a verified `PINNED_BUN` path when registry access is unavailable, or both executable
paths. It must then produce an actual Actions job log with `test:scripts` rc 0
and both worker `actualExitCode: 0` records. Until those resources and results
exist, this is a reviewable provisioning option, not a validated CI lane.
+59 -50
View File
@@ -19,47 +19,47 @@
### App launcher 身份(如何再次唤起本构建)
| 位置 | 机制 | 备注 |
| --- | --- | --- |
| `scripts/cli-entry.js` | 写入 `QWEN_CODE_CLI`(设置了 `QWEN_CODE_LAUNCHER_PATH` 时为 shim 路径,否则为入口文件);捕获并删除 `QWEN_CODE_LAUNCHER_PATH` | launcher 的事实来源 |
| `packages/core/src/services/shellContextEnv.ts` | 将 `QWEN_CODE_CLI` 传播进 shell 子进程环境;已设置但不可用的脚本入口置空 | 消费方使用 `"${QWEN_CODE_CLI:-qwen}"` |
| `packages/cli/src/cli.ts`(`getShellContextEnvVars` 接线) | 对 CLI 启动的所有 shell 做同样的环境传播 | |
| `packages/cli/src/serve/routes/daemon-update.ts` | 读取 `QWEN_CODE_CLI` 作为更新重启的 bootstrap | |
| `packages/cli/src/commands/review/**` | agent 提示词中的 `"${QWEN_CODE_CLI:-qwen}" review …` 自调用 | launcher 身份,已被测试固定 |
| `scripts/cli-entry.js` `relaunchAfterUpdate()` | `findLauncher()` 优先 `QWEN_CODE_LAUNCHER_PATH`,其次按安装前缀打分的 PATH 扫描 | 更新后重启 |
| `packages/cli/src/utils/relaunch.ts`(`relaunchAppInChildProcess`) | 监督式重启:`spawn(process.execPath, [execArgv, argv[1], args], ipc)`;可用时走 `process.execve` | launcher 的同解释器回退 |
| `packages/cli/src/utils/processUtils.ts`(`relaunchApp`) | `execve(process.execPath, [execPath, execArgv, argv…])` | 同解释器回退 |
| `packages/core/src/agents/arena/ArenaManager.ts` | 每个 arena agent `command: process.execPath, args: [resolve(argv[1]), …]` | worktree 内的应用再唤起;必须保持 launcher 形态 |
| `packages/cli/src/serve/managed-runtime-session-worker.ts`(`currentCliWorkerLaunch`) | `command: process.execPath, args: [execArgv, QWEN_CLI_ENTRY \|\| argv[1], 'managed-runtime-worker']` | 应用再唤起 |
| `packages/cli/src/commands/review/run.ts`(agent runner spawn) | `process.execPath` + 入口 | 应用再唤起 |
| 位置 | 机制 | 备注 |
| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `scripts/cli-entry.js` | 写入 `QWEN_CODE_CLI`(设置了 `QWEN_CODE_LAUNCHER_PATH` 时为 shim 路径,否则为入口文件);捕获并删除 `QWEN_CODE_LAUNCHER_PATH` | launcher 的事实来源 |
| `packages/core/src/services/shellContextEnv.ts` | 将 `QWEN_CODE_CLI` 传播进 shell 子进程环境;已设置但不可用的脚本入口置空 | 消费方使用 `"${QWEN_CODE_CLI:-qwen}"` |
| `packages/cli/src/cli.ts`(`getShellContextEnvVars` 接线) | 对 CLI 启动的所有 shell 做同样的环境传播 | |
| `packages/cli/src/serve/routes/daemon-update.ts` | 读取 `QWEN_CODE_CLI` 作为更新重启的 bootstrap | |
| `packages/cli/src/commands/review/**` | agent 提示词中的 `"${QWEN_CODE_CLI:-qwen}" review …` 自调用 | launcher 身份,已被测试固定 |
| `scripts/cli-entry.js` `relaunchAfterUpdate()` | `findLauncher()` 优先 `QWEN_CODE_LAUNCHER_PATH`,其次按安装前缀打分的 PATH 扫描 | 更新后重启 |
| `packages/cli/src/utils/relaunch.ts`(`relaunchAppInChildProcess`) | 监督式重启:`spawn(process.execPath, [execArgv, argv[1], args], ipc)`;可用时走 `process.execve` | launcher 的同解释器回退 |
| `packages/cli/src/utils/processUtils.ts`(`relaunchApp`) | `execve(process.execPath, [execPath, execArgv, argv…])` | 同解释器回退 |
| `packages/core/src/agents/arena/ArenaManager.ts` | 每个 arena agent `command: process.execPath, args: [resolve(argv[1]), …]` | worktree 内的应用再唤起;必须保持 launcher 形态 |
| `packages/cli/src/serve/managed-runtime-session-worker.ts`(`currentCliWorkerLaunch`) | `command: process.execPath, args: [execArgv, QWEN_CLI_ENTRY \|\| argv[1], 'managed-runtime-worker']` | 应用再唤起 |
| `packages/cli/src/commands/review/run.ts`(agent runner spawn) | `process.execPath` + 入口 | 应用再唤起 |
### JS interpreter 身份(如何运行任意 JS 子进程)
| 位置 | 用途 | 本变更是否接入 `getJsInterpreterCommand` |
| --- | --- | --- |
| `packages/core/src/code-mode/host-client.ts`(`hostCommand`) | code-mode host 子进程(`host.js` / 内置 `codeModeHost.js`;dev 为 `--import tsx host.ts`) | 是 |
| `packages/core/src/sandbox/file-worker-client.ts` | sandbox file-worker 子进程(`sandboxAsset('file-worker')`) | 是 |
| `packages/core/src/hooks/hookRunner.ts`(幸存 hook 监督进程) | `spawn(--input-type=commonjs --eval SUPERVISOR …)`,detached | 是 |
| `packages/core/src/hooks/hook-command-cgroup.ts`(`launch`) | cgroup v2 launcher 子进程(`--input-type=commonjs --eval LAUNCHER …`),在 hook 的受托单元内运行 | 暂未(S3)—— 仅 Linux(cgroup v2 平台守卫) |
| `packages/core/src/sandbox/bwrap-execution.ts`(`executeBwrap`) | 将解释器(`realpathSync(process.execPath)`)纳入 bubblewrap allowlist;relay 在其下启动 JS relay | 暂未(S3)—— 仅 Linux(bubblewrap) |
| `packages/core/src/sandbox/landlock-execution.ts`(`executeLandlock`) | 将解释器纳入 landlock allowlist;relay 在其下启动 JS relay | 暂未(S3)—— 仅 Linux(landlock LSM) |
| `packages/core/src/utils/getPty.ts`(`loadPty`) | PTY 门控的 Bun 检测(当前 Bun 下 PTY 禁用 —— **S2 用真实后端替换此分支**) | 检测走 `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts`(`bundledMem0Server`) | 内置 mem0 MCP 子进程;身份判断 `server.command !== process.execPath` | 暂未;须与其身份判断一起迁移(S3) |
| `packages/core/src/agents/arena/ArenaManager.ts` | (另)worker 侧 in-process 运行时 | launcher 形态,见上表 |
| `packages/cli/src/serve/pem-certificate-blocks.ts` | 辅助子进程 spawn | 暂未(S3 清扫) |
| `packages/cli/src/commands/review/test-efficacy.ts` | 经 `process.execPath` 启动 vitest(开发工具路径) | 有意不接入;保持宿主工具身份 |
| 位置 | 用途 | 本变更是否接入 `getJsInterpreterCommand` |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------- |
| `packages/core/src/code-mode/host-client.ts`(`hostCommand`) | code-mode host 子进程(`host.js` / 内置 `codeModeHost.js`;dev 为 `--import tsx host.ts`) | 是 |
| `packages/core/src/sandbox/file-worker-client.ts` | sandbox file-worker 子进程(`sandboxAsset('file-worker')`) | 是 |
| `packages/core/src/hooks/hookRunner.ts`(幸存 hook 监督进程) | `spawn(--input-type=commonjs --eval SUPERVISOR …)`,detached | 是 |
| `packages/core/src/hooks/hook-command-cgroup.ts`(`launch`) | cgroup v2 launcher 子进程(`--input-type=commonjs --eval LAUNCHER …`),在 hook 的受托单元内运行 | 暂未(S3)—— 仅 Linux(cgroup v2 平台守卫) |
| `packages/core/src/sandbox/bwrap-execution.ts`(`executeBwrap`) | 将解释器(`realpathSync(process.execPath)`)纳入 bubblewrap allowlist;relay 在其下启动 JS relay | 暂未(S3)—— 仅 Linux(bubblewrap) |
| `packages/core/src/sandbox/landlock-execution.ts`(`executeLandlock`) | 将解释器纳入 landlock allowlist;relay 在其下启动 JS relay | 暂未(S3)—— 仅 Linux(landlock LSM) |
| `packages/core/src/utils/getPty.ts`(`loadPty`) | PTY 门控的 Bun 检测(当前 Bun 下 PTY 禁用 —— **S2 用真实后端替换此分支**) | 检测走 `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts`(`bundledMem0Server`) | 内置 mem0 MCP 子进程;身份判断 `server.command !== process.execPath` | 暂未;须与其身份判断一起迁移(S3) |
| `packages/core/src/agents/arena/ArenaManager.ts` | (另)worker 侧 in-process 运行时 | launcher 形态,见上表 |
| `packages/cli/src/serve/pem-certificate-blocks.ts` | 辅助子进程 spawn | 暂未(S3 清扫) |
| `packages/cli/src/commands/review/test-efficacy.ts` | 经 `process.execPath` 启动 vitest(开发工具路径) | 有意不接入;保持宿主工具身份 |
标注"暂未"的行由 **S3 迁移清扫**(V8/GC/TLS/env 兼容阶段)负责,该表即其检查清单。cgroup/bwrap/landlock 三行是**仅 Linux** 机制(macOS/Windows 上有平台守卫或不生效),其迁移须对照平台矩阵中的 Linux target 验证;mem0 与 pem 两行适用于所有平台。
### 更新 / 安装身份(必须保持宿主 Node)
| 位置 | 备注 |
| --- | --- |
| `packages/cli/src/utils/managed-npm-update.ts` | 经 `process.execPath` 运行 npm 的 `cli.js`;npm **必须**在 Node 身份下运行,绝不能落入私有内置 Bun —— 这里要保持宿主 Node 解析 |
| `packages/cli/src/ui/handleAutoUpdate.ts` | 选择更新命令;Node 身份路径 |
| `packages/cli/src/serve/daemon-update-restart.ts` | 已在 Bun 下禁用(`process.versions['bun']`)且对 execve 做特性检测 |
| `packages/cli/src/utils/installationInfo.ts` | 以 `process.execPath` 报告 node 路径(信息性) |
| `scripts/build-standalone-release.js` / `create-standalone-package.js` | 固定 Bun 1.3.14、五个 target、`manifest.json`(`runtime`、版本);布局 `bun/bin/bun`、`node/bin/node` |
| 位置 | 备注 |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `packages/cli/src/utils/managed-npm-update.ts` | 经 `process.execPath` 运行 npm 的 `cli.js`;npm **必须**在 Node 身份下运行,绝不能落入私有内置 Bun —— 这里要保持宿主 Node 解析 |
| `packages/cli/src/ui/handleAutoUpdate.ts` | 选择更新命令;Node 身份路径 |
| `packages/cli/src/serve/daemon-update-restart.ts` | 已在 Bun 下禁用(`process.versions['bun']`)且对 execve 做特性检测 |
| `packages/cli/src/utils/installationInfo.ts` | 以 `process.execPath` 报告 node 路径(信息性) |
| `scripts/build-standalone-release.js` / `create-standalone-package.js` | 固定 Bun 1.3.14、五个 target、`manifest.json`(`runtime`、版本);布局 `bun/bin/bun`、`node/bin/node` |
### SDK 消费方
@@ -87,18 +87,18 @@
## Bun 兼容事实(本变更实测)
以本机 Bun 1.4.0 对照 Node 24.19.0(同一 fixture,见
`packages/core/src/utils/runtime-boundary.node-bun.test.ts`)。*交付* 的
`packages/core/src/utils/runtime-boundary.node-bun.test.ts`)。_交付_ 的
Bun 1.3.14 固定版本由打包验收负责,不在此处。
| 行为 | Node 24.19.0 | Bun 1.4.0 |
| --- | --- | --- |
| `--expose-gc script.js` → `typeof gc === 'function'` | 是 | 是(接受 `--expose-gc`) |
| `--input-type=commonjs --eval '…'` | 是 | 是 |
| Node IPC 通道(`spawn stdio:[…,'ipc']`、`process.send` / `on('message')`) | 是 | 是,往返正常 |
| SIGTERM 处理函数 → 优雅 `exit(0)` | 是 | 是 |
| `process.exit(n)` 退出码传播 | 是 | 是 |
| `process.execve` | 是 | 缺失 —— 代码必须特性检测(`relaunch.ts`、`processUtils.ts`、`daemon-update-restart.ts` 已做) |
| PTY(`@lydell/node-pty`) | 是 | **在 `getPty` 中禁用** —— S2 阻塞项 |
| 行为 | Node 24.19.0 | Bun 1.4.0 |
| -------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------- |
| `--expose-gc script.js` → `typeof gc === 'function'` | 是 | 是(接受 `--expose-gc`) |
| `--input-type=commonjs --eval '…'` | 是 | 是 |
| Node IPC 通道(`spawn stdio:[…,'ipc']`、`process.send` / `on('message')`) | 是 | 是,往返正常 |
| SIGTERM 处理函数 → 优雅 `exit(0)` | 是 | 是 |
| `process.exit(n)` 退出码传播 | 是 | 是 |
| `process.execve` | 是 | 缺失 —— 代码必须特性检测(`relaunch.ts`、`processUtils.ts`、`daemon-update-restart.ts` 已做) |
| PTY(`@lydell/node-pty`) | 是 | **在 `getPty` 中禁用** —— S2 阻塞项 |
## 验证计划
@@ -121,8 +121,8 @@ checksum、身份/版本拒绝、激活和回滚;`.sig` 404 仅属 checksum-on
不代表生产签名验证。
两次 smoke 共用一个私有空目录,并在 `finally` 尝试删除,避免执行项目或归档中的
`bunfig.toml` preload。smoke 环境副本移除 NODE_OPTIONS/BUN_OPTIONS,保留
其他变量。安装 manifest 为 null、标量、数组或非法 runtime 时,在下载或锁
`bunfig.toml` preload。smoke 环境副本移除 NODE_OPTIONS/BUN_OPTIONS、
CLI_VERSION 和 QWEN_CODE_RELAUNCH_ARGS,不改变父环境或普通业务变量。安装 manifest 为 null、标量、数组或非法 runtime 时,在下载或锁
分配前拒绝;合法对象缺 runtime 仍保留 legacy Node 回退。分配断言必须有
成功更新的正对照;Bun 1.3.14 的 builtin 绑定观察不可用,仅检查目录清理,
不宣称零分配。此入口避开 Vitest 的 Bun loader/coverage 限制,不改变其历史
@@ -136,22 +136,31 @@ Node 归档(核对发布 SHA256)和固定 @oven Bun 包(核对发布 integ
Smoke 同时按既有 semver 规则比较实际 CLI 与请求版本,保留 `v` 规范化和预发布
兼容。direct 与 prepared 更新的合法旧版/新版错配均须在替换前拒绝。仅版本
preload 正对照通过 `--version` 匹配两个 CLI 入口,并验证身份探针不会执行
preload。清理失败保留原 smoke 结果,可能留下临时目录;吞掉删除异常不能证明
preload。回归还使用真实入口 wrapper 注入 ambient 版本:正确包仍能更新和回滚,
旧/新错配仍拒绝,覆盖双宿主、双 payload 解释器及两种更新模式。Node 在此
使用 wrapper 专项夹具,其生产 bundle 的版本是编译期常量。测试侧 callback
观察在宿主支持 builtin 绑定观察时记录探针阶段、耗时、signal 和 killed,
不改变 10 秒预算,也不能解释已记录的首轮 timeout;其根因仍未确定。
清理失败保留原 smoke 结果,可能留下临时目录;吞掉删除异常不能证明
目录已经删除。
真实远端 CI 执行仍待验证。冻结 PR #14187 head `d14b2ad1` 没有 `ci.yml` run:
堆叠 base 不在该 workflow 的分支过滤范围内。ECS lane 复用 Node 22,尚未实测
真实远端 CI 执行仍待验证。本次冻结 PR #14210 head `c57e2cf8` 与前序
PR #14187 head `d14b2ad1` 均没有 `ci.yml` run:两 PR 当前的堆叠目标分支
均不匹配该 workflow 的 `pull_request` 目标分支过滤。ECS lane 复用 Node 22,尚未实测
其对 nodejs.org 的可达性;该安装或未经核实的池镜像均不能满足固定
Node 24.19.0/Bun 1.3.14 合同。本地脚本结果不能代表远端 lane 通过。
分支过滤、PR base 与手动 dispatch 均维持原状。
若池具有可信 Node 分发副本,CI 模式接受 `QWEN_TEST_NODE_BASE_URL`:以 `/`
结尾的 HTTPS 目录,包含固定归档和 `SHASUMS256.txt`。默认仍为官方目录,
checksum 和固定版本硬断言仍必须通过。也可传入真实的 `PINNED_NODE`/
checksum 和固定版本硬断言仍必须通过。此覆盖仅供应 Node;除非传入已核证的
Bun 1.3.14 `PINNED_BUN`,Bun 仍需访问 npm registry。隔离池采用 Node 分发
副本方案时必须同时供应 `PINNED_BUN`。也可传入真实的 `PINNED_NODE`/
`PINNED_BUN` 可执行文件;driver 在改变 cwd 前将两者解析为绝对路径。
ECS 的镜像或固定预装工具尚未落实。待验证的最小 workflow 修改是在既有
脚本测试步骤传入已核证的池资源,例如
`QWEN_TEST_NODE_BASE_URL: ${{ vars.QWEN_TEST_NODE_BASE_URL }}`,或两条解释器路径。
`QWEN_TEST_NODE_BASE_URL: ${{ vars.QWEN_TEST_NODE_BASE_URL }}`,registry 不可达时
同时传入已核证的 `PINNED_BUN` 路径;或传入两条解释器路径。
随后必须获得真实 Actions job 日志,证明 `test:scripts` rc 0 和两个 worker 的
`actualExitCode: 0`。资源与结果未落实前,这只是可评审的配置方案,不是已验证
的 CI lane。
+23 -8
View File
@@ -726,6 +726,10 @@ describe('standalone-update', () => {
vi.stubEnv('NODE_OPTIONS', `--require ${preload}`);
vi.stubEnv('BUN_OPTIONS', `--preload ${preload}`);
vi.stubEnv('QWEN_UPDATE_TEST_ENV', 'preserved');
vi.stubEnv('CLI_VERSION', '9.9.9');
vi.stubEnv('QWEN_CODE_RELAUNCH_ARGS', '["--help"]');
vi.stubEnv('cli_version', '9.9.9');
vi.stubEnv('qwen_code_relaunch_args', '["--help"]');
for (const name of ['TMPDIR', 'TEMP', 'TMP']) vi.stubEnv(name, tempDir);
const installedManifest = JSON.stringify({ target, runtime });
fs.writeFileSync(
@@ -738,7 +742,7 @@ describe('standalone-update', () => {
target,
interpreterPath: process.execPath,
cliSource:
'if (process.env.QWEN_UPDATE_TEST_ENV !== "preserved") process.exit(9); console.log("1.2.3");',
'if (process.env.QWEN_UPDATE_TEST_ENV !== "preserved" || Object.keys(process.env).some(key => /^(CLI_VERSION|QWEN_CODE_RELAUNCH_ARGS)$/i.test(key))) process.exit(9); console.log("1.2.3");',
});
expect(await performStandaloneUpdate(standaloneDir, '1.2.3')).toBe(
'done',
@@ -750,6 +754,10 @@ describe('standalone-update', () => {
).toBe('1.2.3');
expect(process.env['NODE_OPTIONS']).toContain(preload);
expect(process.env['BUN_OPTIONS']).toContain(preload);
expect(process.env['CLI_VERSION']).toBe('9.9.9');
expect(process.env['QWEN_CODE_RELAUNCH_ARGS']).toBe('["--help"]');
expect(process.env['cli_version']).toBe('9.9.9');
expect(process.env['qwen_code_relaunch_args']).toBe('["--help"]');
expect(
fs
.readdirSync(tempDir)
@@ -891,13 +899,18 @@ describe('standalone-update', () => {
);
it.skipIf(process.platform === 'win32').each([
['direct', '0.0.9'],
['direct', '9.9.9'],
['prepared', '0.0.9'],
['prepared', '9.9.9'],
['direct', '0.0.9', false],
['direct', '9.9.9', false],
['prepared', '0.0.9', false],
['prepared', '9.9.9', false],
['direct', '0.0.9', true],
['direct', '9.9.9', true],
['prepared', '0.0.9', true],
['prepared', '9.9.9', true],
])(
'rejects a legal CLI version mismatch before replacing live or backup (%s, %s)',
async (mode, actualVersion) => {
'rejects a legal CLI version mismatch before replacing live or backup (%s, %s, ambient: %s)',
async (mode, actualVersion, ambient) => {
if (ambient) vi.stubEnv('CLI_VERSION', '1.2.3');
for (const name of ['TMPDIR', 'TEMP', 'TMP']) vi.stubEnv(name, tempDir);
const runtime = process.versions['bun'] ? 'bun' : 'node';
const target = `${os.platform()}-${os.arch()}`;
@@ -918,7 +931,9 @@ describe('standalone-update', () => {
runtime,
target,
interpreterPath: process.execPath,
cliSource: `console.log(${JSON.stringify(actualVersion)});`,
cliSource: ambient
? `console.log(process.env.CLI_VERSION || ${JSON.stringify(actualVersion)});`
: `console.log(${JSON.stringify(actualVersion)});`,
});
const envBefore = { ...process.env };
const pending =
+7 -2
View File
@@ -436,9 +436,14 @@ function spawnAndCapture(
cwd: string,
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const env = { ...process.env };
// Probe the shipped interpreter and CLI without inherited preload hooks.
// Probe the shipped interpreter and CLI without inherited startup overrides.
for (const key of Object.keys(env)) {
if (/^(NODE_OPTIONS|BUN_OPTIONS)$/i.test(key)) delete env[key];
if (
/^(NODE_OPTIONS|BUN_OPTIONS|CLI_VERSION|QWEN_CODE_RELAUNCH_ARGS)$/i.test(
key,
)
)
delete env[key];
}
return new Promise((resolve, reject) => {
let settled = false;
+181 -1
View File
@@ -9,7 +9,7 @@ import os from 'node:os';
import path from 'node:path';
import https from 'node:https';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import childProcess, { spawnSync } from 'node:child_process';
import { syncBuiltinESMExports } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -45,6 +45,30 @@ async function worker(root, node, bun, expectedHost) {
binarySha256: sha(process.execPath),
}),
);
const started = performance.now();
let phase = 'setup';
const originalExecFile = childProcess.execFile;
let probeCount = 0;
childProcess.execFile = (command, args, options, callback) => {
const probeStarted = performance.now();
const probePhase = args[0] === '-e' ? 'identity' : 'version';
return originalExecFile(command, args, options, (err, out, stderr) => {
probeCount++;
console.log(
JSON.stringify({
host,
phase,
probePhase,
elapsedMs: Math.round(performance.now() - probeStarted),
code: err?.code ?? 0,
signal: err?.signal ?? null,
killed: err?.killed ?? false,
}),
);
callback(err, out, stderr);
});
};
syncBuiltinESMExports();
const originalEnv = { ...process.env };
const originalCwd = process.cwd();
const tmp = path.join(root, 'tmp');
@@ -216,6 +240,7 @@ async function worker(root, node, bun, expectedHost) {
'legacy',
]) {
if (scenario === 'legacy' && runtime !== 'node') continue;
phase = `${runtime}/${scenario}`;
const dir = path.join(root, `${runtime}-${scenario}`);
const live = installFixture(dir, {
version: '1.0.0',
@@ -354,6 +379,7 @@ async function worker(root, node, bun, expectedHost) {
delete process.env.BUN_OPTIONS;
for (const mode of ['direct', 'prepared']) {
for (const actualVersion of ['0.0.9', '9.9.9']) {
phase = `${runtime}/${mode}/mismatch/${actualVersion}`;
pack(
`if(process.env.QWEN_UPDATE_TEST_ENV!=='preserved')process.exit(19);console.log(${JSON.stringify(actualVersion)});`,
);
@@ -400,6 +426,7 @@ async function worker(root, node, bun, expectedHost) {
['v1.2.3', '1.2.3', 'v1.2.3'],
['1.2.3-rc.1', '1.2.3-rc.1', 'v1.2.3-rc.1'],
]) {
phase = `${runtime}/${mode}/normalized/${requested}`;
pack(`console.log(${JSON.stringify(actual)});`, manifest);
const dir = path.join(root, `${runtime}-${mode}-${requested}`);
const live = installFixture(dir, {
@@ -449,6 +476,137 @@ async function worker(root, node, bun, expectedHost) {
);
}
}
// Use the production wrapper verbatim, including its ambient version
// fast path. Node also runs this wrapper-specific fixture; its normal
// bundled CLI is covered by the original fixed-output cases above.
const wrapper = fs.readFileSync(
path.join(repo, 'scripts/cli-entry.js'),
'utf8',
);
for (const mode of ['direct', 'prepared']) {
for (const actualVersion of ['1.2.3', '0.0.9', '9.9.9']) {
phase = `${runtime}/${mode}/ambient/${actualVersion}`;
const legStarted = performance.now();
const ambientVersion = actualVersion === '1.2.3' ? '9.9.9' : '1.2.3';
fs.writeFileSync(
path.join(payload, 'lib/package.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
type: 'module',
version: actualVersion,
}),
);
pack(wrapper);
const controlEnv = { ...process.env };
for (const key of Object.keys(controlEnv))
if (
/^(NODE_OPTIONS|BUN_OPTIONS|CLI_VERSION|QWEN_CODE_RELAUNCH_ARGS)$/i.test(
key,
)
)
delete controlEnv[key];
assert.equal(
run(binaries[runtime], [cli, '--version'], { env: controlEnv }),
actualVersion,
);
assert.equal(
run(binaries[runtime], [cli, '--version'], {
env: { ...controlEnv, CLI_VERSION: ambientVersion },
}),
ambientVersion,
'wrapper pollution positive control',
);
const dir = path.join(
root,
`${runtime}-${mode}-ambient-${actualVersion}`,
);
const live = installFixture(dir, {
version: '1.0.0',
runtime,
target,
});
const before = snapshot(dir);
const parentEnv = { ...process.env };
process.env.CLI_VERSION = ambientVersion;
process.env.QWEN_CODE_RELAUNCH_ARGS = '["--version"]';
const envBefore = { ...process.env };
let pending;
try {
pending =
mode === 'prepared'
? await prepareStandaloneUpdate(live, '1.2.3')
: undefined;
const update = pending
? pending.activate()
: performStandaloneUpdate(live, '1.2.3');
if (actualVersion === '1.2.3') {
assert.equal(await update, 'done');
assert.equal(
sha(path.join(live, 'lib', path.basename(cli))),
sha(cli),
);
assert.equal(
JSON.parse(fs.readFileSync(path.join(live, 'manifest.json')))
.version,
'1.2.3',
);
assert.deepEqual(
snapshot(live + '.old').filter(
([name]) => name !== '.qwen-rollback-info.json',
),
before.find(([name]) => name === 'installed')[1],
);
assert.deepEqual(rollbackStandaloneUpdate(live), { ok: true });
assert.deepEqual(
snapshot(live).filter(
([name]) => name !== '.qwen-rollback-info.json',
),
before.find(([name]) => name === 'installed')[1],
);
assert.equal(fs.existsSync(live + '.old'), false);
} else {
await assert.rejects(update, {
message:
'Smoke test failed: CLI version does not match the expected version',
});
assert.deepEqual(snapshot(dir), before);
}
assert.deepEqual({ ...process.env }, envBefore);
assert.equal(fs.existsSync(archiveMarker), false);
} finally {
pending?.cleanup();
for (const key of ['CLI_VERSION', 'QWEN_CODE_RELAUNCH_ARGS']) {
if (key in parentEnv) process.env[key] = parentEnv[key];
else delete process.env[key];
}
}
assert.deepEqual({ ...process.env }, parentEnv);
assert.deepEqual(fs.readdirSync(tmp), []);
assert.deepEqual(
fs.readdirSync(dir).sort(),
actualVersion === '1.2.3'
? ['installed']
: ['installed', 'installed.old'],
);
console.log(
JSON.stringify({
host,
runtime,
mode,
actualVersion,
ambientVersion,
wrapperSha256: sha(path.join(repo, 'scripts/cli-entry.js')),
scenario: 'ambient-wrapper',
result:
actualVersion === '1.2.3'
? 'updated-and-rolled-back'
: 'rejected',
elapsedMs: Math.round(performance.now() - legStarted),
}),
);
}
}
fs.rmSync(path.join(payload, 'lib/package.json'));
// Real interpreter failures: malformed CLI version and nonzero exit.
for (const [failure, source, message] of [
[
@@ -462,6 +620,7 @@ async function worker(root, node, bun, expectedHost) {
'Smoke test failed: new binary exited with code 23',
],
]) {
phase = `${runtime}/${failure}`;
pack(source);
const dir = path.join(root, `${runtime}-${failure}`);
const live = installFixture(dir, { runtime, target });
@@ -474,6 +633,7 @@ async function worker(root, node, bun, expectedHost) {
console.log(JSON.stringify({ runtime, failure, rejected: true }));
}
// A real interpreter of the OTHER runtime must not satisfy identity.
phase = `${runtime}/identity-mismatch`;
fs.copyFileSync(
binaries[runtime === 'bun' ? 'node' : 'bun'],
path.join(payload, runtime, 'bin', runtime),
@@ -506,6 +666,7 @@ async function worker(root, node, bun, expectedHost) {
{ target, runtime: null },
{ target, runtime: 'deno' },
].entries()) {
phase = `manifest/${mode}/${index}`;
const dir = path.join(root, `manifest-${mode}-${index}`);
const live = installFixture(dir, manifest);
const before = snapshot(dir),
@@ -537,9 +698,25 @@ async function worker(root, node, bun, expectedHost) {
complete: true,
unsignedFixture: 'checksum-only; signature 404',
tlsVerification: true,
elapsedMs: Math.round(performance.now() - started),
probeCount,
rawProbeObservable: probeCount > 0,
tmpResidue: fs.readdirSync(tmp),
}),
);
} catch (err) {
console.error(
JSON.stringify({
host,
failurePhase: phase,
elapsedMs: Math.round(performance.now() - started),
probeCount,
rawProbeObservable: probeCount > 0,
}),
);
throw err;
} finally {
childProcess.execFile = originalExecFile;
process.chdir(originalCwd);
fs.mkdtempSync = originalMkdtemp;
syncBuiltinESMExports();
@@ -689,6 +866,7 @@ if (process.argv[2] === '--ci') {
]) {
const dir = path.join(root, host);
fs.mkdirSync(dir);
const workerStarted = performance.now();
const result = spawnSync(
bin,
[script, '--worker', dir, node, bun, host],
@@ -707,6 +885,8 @@ if (process.argv[2] === '--ci') {
host,
actualExitCode: result.status,
signal: result.signal,
errorCode: result.error?.code ?? null,
elapsedMs: Math.round(performance.now() - workerStarted),
}),
);
assert.equal(result.error, undefined);