refactor(core): separate CLI launcher from JS interpreter (MYWO-8) (#14036)

* feat(core): separate app launcher and JS interpreter runtime boundary

Establishes the runtime boundary the Bun migration (MYWO-8 S1) needs
before workers, hooks, sandbox helpers and the code-mode host spawn
JavaScript children under a future compiled-app flavor: arbitrary-JS
child processes resolve their interpreter through one accessor (package
manifest first, process.execPath fallback) instead of reading
process.execPath directly, Bun detection has a single seam, and the
app-launcher contract is documented alongside the full call-site
inventory in a bilingual design doc. Adds the same-fixture Node/Bun
regression matrix (stdio framing, exit codes, SIGTERM, IPC channel,
--expose-gc, --input-type=commonjs --eval).

* fix(core): anchor interpreter resolution to the trusted install root

Arbitrary-JS child resolution no longer walks upward looking for a
manifest: the only trusted location is the standalone package root —
the parent of the caller's bundle directory, which must hold both a
manifest.json and the standalone entry marker lib/cli-entry.js — so an
unrelated ancestor manifest in npm, source or SDK layouts can never be
read. Declarations are validated before use: runtime must be exactly
'bun' or 'node', and the interpreter must be an existing executable
regular file; any invalid or missing declaration falls back to
process.execPath without throwing (non-string interpreter, directory,
non-executable file, unknown runtime, absent binary, missing marker).

Adds the regression matrix for unrelated ancestor manifests (npm
bundle and deep SDK layouts), object/number/directory interpreters,
unknown runtime, missing and non-executable interpreters, and the
trust anchor; syncs both design-doc language versions with the
validated contract.

* docs(design): complete the migration inventory handoff

Adds the three interpreter-identity call sites the re-review found
missing from the call-site inventory (hook-command-cgroup launcher,
bwrap-execution and landlock-execution allowlist staging), marks the
unwired rows as owned by the S3 migration sweep, and notes platform
applicability: the cgroup/bwrap/landlock rows are Linux-only
mechanisms validated against the Linux targets of the platform
matrix, while the mem0 and pem rows apply on every platform. Both
design-doc language versions are updated; no product or test code
changes.

* style: apply prettier to the bun boundary docs and runtime tests

Fixes the failing Lint & Static lane: `prettier --experimental-cli
--check .` (prettier@3.6.1, the lockfile resolution) rejects the two
bun-runtime-boundary design docs and the two runtime-info test files
introduced by the runtime boundary change. Formatting only — markdown
table alignment, emphasis markers and code line wrapping; no content
or behavior changes.

* test(core): harden runtime boundary fixtures and document validation scope

* test(core): reject stderr errors while awaiting fixture identity

* test(core): expose Bun version and complete runtime call-site inventory

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: gary.gq <gary.gq@alibaba-inc.com>
This commit is contained in:
ChiGao 2026-10-16 11:41:51 +08:00 • committed by GitHub
parent 9280ef9845
commit 2eab3cc431
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1222 additions and 6 deletions

View file

@ -0,0 +1,246 @@
# Bun Runtime Boundary: App Launcher vs JS Interpreter
[English](bun-runtime-boundary.md) | [简体中文](bun-runtime-boundary.zh-CN.md)
Status: implemented in MYWO-8 S1 (this change). Audience: S2 (Bun PTY), S3
(runtime compat matrix), S4 (packaging), and qoder's PTY integration work.
## Problem
`process.execPath` conflates two distinct identities:
- 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.
They agree today (npm: host Node; standalone: the bundled runtime launches
`lib/cli-entry.js`, so `process.execPath` is always a JS interpreter). They
diverge the moment a compiled-app flavor (`bun build --compile`) exists:
`process.execPath` becomes the application binary, which cannot run arbitrary
JS files. Every worker spawn that re-reads `process.execPath` directly would
break at once and silently. The Bun runtime migration (MYWO-8) is exactly the
project that will introduce that flavor, so the boundary must be explicit
before S2/S3/S4 build on it.
## Current state: call-site inventory
The conceptual tables originated at `main`
`d8558bba0198c1cdadac1523940c88c04b038f62`. They are not exhaustive;
the fixed-parent literal call-site audit below supplements them with every
scoped occurrence and its exclusions. Classification does not prove migration
or compatibility.
### 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 |
### 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 |
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
cgroup/bwrap/landlock rows are **Linux-only** mechanisms (platform-guarded or
inert on macOS/Windows), so their migration is validated against the Linux
targets of the platform matrix; the mem0 and pem rows apply on every
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` | defaults npm CLI path resolution to `process.execPath` (host Node) |
| `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` |
### Complete literal call-site audit at the fixed parent
The tables above are a historical conceptual grouping, not an exhaustive list.
This table accounts for every executable literal `process.execPath` read in
`packages/**/src/**` at parent
`8b4a0a25ea2bc570a1a6070e05182f29501a1b7f`: 34 files, 48 occurrences.
Line numbers refer to that parent; different roles within a file are explicit.
The sweep excludes comments, `*.test.*`, `*.spec.*`, `__tests__`, `test`
directories, `*test-helper*`, and `acceptance` directories; exceptions are
accounted for below. This is source classification, not platform or compiled
artifact compatibility evidence, nor an audit of every implicit fork, alias,
external command, or native spawn. Repeat this scoped sweep after changes.
| Fixed source location (all lines) | Identity, current behavior and follow-up scope |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/acp-bridge/src/spawnChannel.ts:554` | Launcher: ACP CLI child with heap arguments; not migrated (S4). |
| `packages/browser-use/src/native-host-installer.ts:378` | Interpreter: persists the native-host runtime in its shell launcher; not migrated (S3); installation lifetime and host compatibility need validation. |
| `packages/channels/base/src/AcpBridge.ts:141` | Launcher: CLI entry with --acp and inherited runtime flags; not migrated (S4). |
| `packages/cli/src/agent-view/current-cli-argv.ts:27,34` | Launcher: development tsx entry (27), ordinary entry (34); neither migrated (S4). |
| `packages/cli/src/commands/review/run.ts:602` | Launcher: CLI review agent with --expose-gc; not migrated (S4). |
| `packages/cli/src/commands/review/test-efficacy.ts:1849` | Host tooling: local Vitest entry; intentionally retains its test host, outside app-interpreter migration. |
| `packages/cli/src/commands/sandbox.ts:228` | Interpreter: sandbox -e diagnostic, realpath allowlist; not migrated (S3, Linux). |
| `packages/cli/src/config/mem0-settings.ts:153,208` | Interpreter: MCP child (153) and matching identity check (208); migrate together (S3), neither changed here. |
| `packages/cli/src/serve/channel-worker-supervisor.ts:1119` | Launcher: channel daemon-worker entry with IPC; not migrated (S4). |
| `packages/cli/src/serve/daemon-update-restart.ts:99,100` | Launcher: execve executable and argv[0]; Bun-disabled path, not compiled-app support (S4). |
| `packages/cli/src/serve/local-process-runtime-activator.ts:315` | Launcher: configured managed-runtime entry arguments; not migrated (S4). |
| `packages/cli/src/serve/managed-runtime-session-worker.ts:250` | Launcher: managed-runtime-worker entry; not migrated (S4). |
| `packages/cli/src/serve/pem-certificate-blocks.ts:83` | Interpreter: CommonJS PEM oracle; not migrated (S3); runtime crypto semantics remain to be verified. |
| `packages/cli/src/serve/run-qwen-serve.ts:913` | Interpreter: module eval TLS trust probe; not migrated (S3); preserve Node/Bun TLS distinctions. |
| `packages/cli/src/serve/session-agents/orchestrator.ts:631` | Launcher: agents session-send-mcp entry; not migrated (S4). |
| `packages/cli/src/ui/handleAutoUpdate.ts:124` | Host Node: npm update command; retain host identity, not private Bun (S3 validation pending). |
| `packages/cli/src/ui/utils/updateCheck.ts:148` | Host Node: npm update-check runner default; retain host identity (S3 validation pending). |
| `packages/cli/src/utils/installationInfo.ts:27` | Host Node: default for npm CLI path resolution, not merely informational; retain with npm consumers. |
| `packages/cli/src/utils/managed-npm-update.ts:118,120,137,188,329,331` | Host Node: npm config/path resolution (118/120/137), staged CLI health launch (188), npm install and path lookup (329/331); retain host identity, validate each stage (S3). |
| `packages/cli/src/utils/processUtils.ts:68,69` | Launcher: execve executable and argv[0]; not migrated for compiled apps (S4). |
| `packages/cli/src/utils/relaunch.ts:167,168,190` | Launcher: execve executable/argv[0] (167/168), supervised IPC spawn fallback (190); not migrated for compiled apps (S4). |
| `packages/core/src/agents/arena/ArenaManager.ts:1096` | Launcher: argv[1] in agent worktree; not migrated (S4). |
| `packages/core/src/hooks/hook-command-cgroup.ts:166` | Interpreter: CommonJS eval cgroup launcher; not migrated (S3, Linux cgroup v2). |
| `packages/core/src/sandbox/bwrap-execution.ts:41` | Interpreter: realpath staging for JS relay; not migrated (S3, Linux bubblewrap). |
| `packages/core/src/sandbox/landlock-execution.ts:113` | Interpreter: realpath staging for JS relay; not migrated (S3, Linux landlock). |
| `packages/core/src/utils/runtime-info.ts:175` | Interpreter resolver fallback: host executable on rejected/missing manifest; implemented S1, compiled flavor unsupported. |
| `packages/mobile-mcp/src/server.ts:189` | Information: telemetry identity hash input; no child launch, intentionally outside migration. |
| `packages/node-repl/src/desktop-relay/cli.ts:89,174,192` | Mixed: npm host interpreter (89); persisted launchd relay app entry (174, S4 pending); permission guidance text (192, informational). Do not replace all three blindly. |
| `packages/node-repl/src/desktop-relay/runtime.ts:53` | Interpreter: node-repl package entry, not Qwen CLI launcher; not migrated (S3), package/runtime boundary must be preserved. |
| `packages/node-repl/src/kernel-manager.ts:567` | Interpreter: VM kernel with experimental flags and fd3/fd4 protocol; host Node contract, no automatic Bun substitution (S3 compatibility pending). |
| `packages/sdk-typescript/src/utils/cliPath.ts:313,340` | Launcher: bundled CLI default (313); interpreter: explicit JS executable (340). SDK-host identity, intentionally no private-Bun override; compiled handling pending S4. |
| `packages/vscode-ide-companion/src/extension.ts:409` | Launcher: extension-host CLI terminal command; preserve Electron/VSCode environment contract, not private Bun (S4 pending). |
| `packages/vscode-ide-companion/src/services/acpConnection.ts:109` | Launcher: extension-host ACP entry; preserve Electron run-as-Node contract (S4 pending). |
| `packages/vscode-ide-companion/src/services/qwenDaemonProcess.ts:76` | Launcher: extension-host serve entry; preserve Electron run-as-Node contract (S4 pending). |
| `scripts/cli-entry.js:476,488` | Launcher: Bun/Windows `spawnSync(--expose-gc, cliPath)` branch and error text; not migrated for compiled apps (S4), not interpreter-selector wiring. |
| `scripts/dev.js:237` | Development host interpreter selection; intentionally retains the development tool host, outside standalone production migration. |
Scope exceptions: literal matches in `packages/sdk-typescript/src/transport/ProcessTransport.ts` are comments; its actual `forkOptions.execPath` consumes the selection from `cliPath.ts` and needs joint SDK/Electron contract validation. The `execPath` parameter reads at `channel-worker-supervisor.ts:196,271,280` consume the 1119 call in the table. Other literal matches in `core/src/utils/runtime-info.ts` are comments.
Intentional production-migration exclusions: `packages/cli/src/serve/cdp-tunnel/acceptance/run-real-chrome.mjs:95,112,129` is an acceptance driver; `packages/cli/src/serve/managed-runtime-tool-v3-host.test-helper.ts:44` is a test helper; all tests/fixtures and `packages/sdk-java/**/src/test/**` are host tests. Remaining matches in `packages/**/scripts/**`, package-root `build.mjs`, and `scripts/**` are build, packaging, development, benchmark, sandbox prototype, lint or E2E drivers. They retain their tool host and are not migrated application sites. Script spawns in `scripts/build-standalone-release.js` are build-host behavior, not proof that generated archives run. Runtime strings in packaging scripts are layout configuration requiring separate unmodified-archive acceptance.
The three S1 selector consumers are `core/src/code-mode/host-client.ts`, `core/src/sandbox/file-worker-client.ts` and `core/src/hooks/hookRunner.ts` (each under `packages/`). They no longer read `process.execPath` directly and remain part of the inventory despite no literal match. `getPty.ts` detects the runtime; it does not spawn an interpreter. S3/S4 designate pending work, not submitted or approved migrations; kernel, npm, SDK and extension hosts must not all be replaced with private Bun.
### SDK consumers
`@qwen-code/qwen-code-core` used as a library (SDK hosts) runs under the
host's own runtime. SDK code paths must not force the Bun interpreter on a
Node host: `getJsInterpreterCommand` only deviates from `process.execPath`
when a standalone `manifest.json` exists in the package layout, which npm and
source installs do not have.
## Contract
### 1. App launcher
- `scripts/cli-entry.js` remains the only writer of `QWEN_CODE_CLI`;
`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
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
`QWEN_CODE_CLI` before those sites can change — they are pinned by the
inventory above as source locations, not verified compiled-app behavior.
### 2. JS interpreter — `packages/core/src/utils/runtime-info.ts`
- `isBunRuntime(): boolean` — single runtime-detection point.
- `getJsInterpreterCommand(importMetaUrl): { command, runtime }` — resolves
the interpreter for arbitrary-JS children:
1. The only trusted location is the **standalone package root**: the
parent of the caller's bundle directory (`resolveBundleDir`), which
must hold BOTH a `manifest.json` and the standalone entry marker
`lib/cli-entry.js`. There is **no upward traversal** — an unrelated
ancestor manifest (an npm host project's build manifest) can never be
picked up, so npm, source and SDK layouts always keep
`process.execPath`.
2. Within that trusted root every declaration is validated before use:
`runtime` must be exactly `'bun'` or `'node'`, and the interpreter —
the `interpreter` field resolved against the package root (absolute paths
are used as-is; no containment is enforced), else the
standard `bun/bin/bun` / `node/bin/node` location — must be an existing
regular file. POSIX additionally requires execute permission; Windows
checks regular-file existence only, not PE validity or launchability.
The Windows default paths are `bun/bun.exe` and `node/node.exe`.
3. Any invalid or missing declaration — non-object manifest, unknown
`runtime`, non-string `interpreter`, missing binary, directory,
non-executable POSIX file, missing entry marker — falls back to
`process.execPath`, the interpreter running this process (host Node,
bundled Node, or the private bundled Bun). The resolution never
guesses at a different binary and never throws.
- `runtime` is metadata with no current production read site: the three
migrated callers consume only `command`. Runtime-specific consumers,
including any future S3 use of this field, are not implemented here.
- Result memoized per process: the first caller's URL determines resolution;
later caller URLs are ignored. Current production callers share one
installation root. `resetJsInterpreterCacheForTests()` clears the cache.
- The sandbox file-worker selects this interpreter, but bwrap/landlock still
stage `realpathSync(process.execPath)` in their allowlists. That coupling
remains an unimplemented S3 migration; this PR does not establish support
for layouts where the resolved interpreter differs from the staged binary.
- Supported scope: **interpreter-based Node/Bun distributions**. Rejection
returns `process.execPath`, which would be the application binary in a
compiled build. This PR does not support compiled applications or provide
a compiled-artifact producer. That future flavor needs explicit interpreter
packaging and separately verified failure behavior before adoption; the
present fallback does not guarantee a usable interpreter in that flavor.
Migration rule: any new code that spawns a JS file or `-e` snippet as a child
goes through `getJsInterpreterCommand`; sites that re-invoke the app go
through `QWEN_CODE_CLI` / the entry fallback.
## Cross-runtime test coverage
`packages/core/src/utils/runtime-boundary.node-bun.test.ts` runs the same
fixture under Node and an available Bun interpreter. It covers identity and
stdio JSON framing, exit-code propagation, POSIX SIGTERM handling, IPC
round-trips with explicit shutdown, `--expose-gc`, and
`--input-type=commonjs --eval`. These are test contracts, not a claim that all
platforms or packaged artifacts have passed. Windows skips the catchable
SIGTERM case. An absent Bun binary skips the Bun leg; record the actual
runtime versions and skips when reporting a run. The shipped Bun 1.3.14 pin
and standalone artifacts require separate packaging acceptance.
This fixture does not probe `process.execve`, native PTY loading, fd-3 status
pipes or process-group signaling. `process.execve` feature detection at
relaunch sites and Bun gating in `getPty` are source mechanisms, not results
from this matrix. Native PTY and process-tree behavior need their own real
process and artifact evidence.
## Validation plan
- `packages/core/src/utils/runtime-info.test.ts` — manifest resolution,
fallbacks, memoization.
- `packages/core/src/utils/runtime-boundary.node-bun.test.ts` — the
same-fixture coverage described above; the Bun leg skips when no Bun
binary exists. A skipped leg is not a compatibility pass.
- S2/S3 consume the inventory tables as their migration checklist; S5 reruns
the matrix on the real archives per the MYWO-8 acceptance protocol.
## Open questions
- `ArenaManager` / managed-worker relaunch under a future compiled flavor:
the launcher must be published by the compiled package (`QWEN_CODE_CLI`),
and the same-interpreter fallback sites above need a launcher-shaped
migration. Tracked for S4.
- `mem0-settings.ts` identity check couples `command === process.execPath`;
migrate it together with its spawn site in S3 so the identity cannot drift
from the launcher.

View file

@ -0,0 +1,166 @@
# Bun 运行时边界:App Launcher 与 JS Interpreter 分离
[English](bun-runtime-boundary.md) | [简体中文](bun-runtime-boundary.zh-CN.md)
状态:MYWO-8 S1 已实现(本变更)。读者:S2(Bun PTY)、S3(运行时兼容矩阵)、S4(打包)以及 qoder 的 PTY 对接工作。
## 问题
`process.execPath` 混淆了两个不同的身份:
- **app launcher(应用启动器)** —— 子进程如何触达*当前这一份* CLI 构建;
- **JS interpreter(JS 解释器)** —— 以子进程方式运行任意 JavaScript 文件或 `-e` 片段的二进制。
两者今天恰好一致(npm:宿主 Node;standalone:内置运行时启动 `lib/cli-entry.js`,因此 `process.execPath` 始终是一个 JS 解释器)。一旦出现编译产物形态(`bun build --compile`),两者就会分叉:`process.execPath` 变成应用二进制,无法运行任意 JS 文件。所有直接读 `process.execPath` 的 worker spawn 会同时、静默地失效。Bun 运行时迁移(MYWO-8)正是要引入该形态的项目,因此必须在 S2/S3/S4 依赖它之前把边界显式化。
## 现状:调用点清单
概念表源自 `main` `d8558bba0198c1cdadac1523940c88c04b038f62`,不是穷尽列表;下方固定父提交的字面调用点复核补齐范围内的每处命中及排除项。分类不代表迁移或兼容性已获证明。
### 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` + 入口 | 应用再唤起 |
### 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(开发工具路径) | 有意不接入;保持宿主工具身份 |
标注"暂未"的行由 **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` | npm CLI 路径解析默认使用 `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` |
### 固定父提交的完整字面调用点复核
上表是按概念分组的历史清单,不是穷尽列表。本表补齐固定父提交
`8b4a0a25ea2bc570a1a6070e05182f29501a1b7f` 上 `packages/**/src/**`
的所有可执行 `process.execPath` 字面读取:34 文件、48 处。行号固定于该父提交;
同文件不同用途逐一列出。扫描排除注释、`*.test.*`、`*.spec.*`、`__tests__`、
`test` 目录、`*test-helper*` 与 `acceptance` 目录;下面明确记录例外。
此清单是源码分类,不是平台/编译产物兼容性证明,也不是所有隐式 fork、别名、
外部命令或原生 spawn 的完整审计。后续改动需重跑同范围扫描。
| 固定源码位置(所有行号) | 身份、当前行为及后续范围 |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/acp-bridge/src/spawnChannel.ts:554` | 启动器:带堆参数的 ACP CLI 子进程;未迁移(S4)。 |
| `packages/browser-use/src/native-host-installer.ts:378` | 解释器:将 native-host 运行时持久写入 shell 启动器;未迁移(S3),需验证安装生命周期和宿主兼容性。 |
| `packages/channels/base/src/AcpBridge.ts:141` | 启动器:CLI 入口、--acp 及继承的运行时参数;未迁移(S4)。 |
| `packages/cli/src/agent-view/current-cli-argv.ts:27,34` | 启动器:开发态 tsx 入口(27)、普通入口(34);均未迁移(S4)。 |
| `packages/cli/src/commands/review/run.ts:602` | 启动器:带 --expose-gc 的 CLI review agent;未迁移(S4)。 |
| `packages/cli/src/commands/review/test-efficacy.ts:1849` | 宿主工具:本地 Vitest 入口;有意保留测试宿主,不属应用解释器迁移。 |
| `packages/cli/src/commands/sandbox.ts:228` | 解释器:沙箱 -e 诊断与 realpath 白名单;未迁移(S3、Linux)。 |
| `packages/cli/src/config/mem0-settings.ts:153,208` | 解释器:MCP 子进程(153)及匹配身份检查(208);需一同迁移(S3),本 PR 均未修改。 |
| `packages/cli/src/serve/channel-worker-supervisor.ts:1119` | 启动器:带 IPC 的 channel daemon-worker 入口;未迁移(S4)。 |
| `packages/cli/src/serve/daemon-update-restart.ts:99,100` | 启动器:execve 可执行文件及 argv[0];Bun 下禁用的路径,不代表编译应用支持(S4)。 |
| `packages/cli/src/serve/local-process-runtime-activator.ts:315` | 启动器:配置的 managed-runtime 入口参数;未迁移(S4)。 |
| `packages/cli/src/serve/managed-runtime-session-worker.ts:250` | 启动器:managed-runtime-worker 入口;未迁移(S4)。 |
| `packages/cli/src/serve/pem-certificate-blocks.ts:83` | 解释器:CommonJS PEM oracle;未迁移(S3),运行时 crypto 语义待验证。 |
| `packages/cli/src/serve/run-qwen-serve.ts:913` | 解释器:module eval TLS 信任探针;未迁移(S3),须保留 Node/Bun TLS 差异。 |
| `packages/cli/src/serve/session-agents/orchestrator.ts:631` | 启动器:agents session-send-mcp 入口;未迁移(S4)。 |
| `packages/cli/src/ui/handleAutoUpdate.ts:124` | 宿主 Node:npm 更新命令;保留宿主身份,不改用私有 Bun(S3 验证待补)。 |
| `packages/cli/src/ui/utils/updateCheck.ts:148` | 宿主 Node:npm 更新检查运行器默认值;保留宿主身份(S3 验证待补)。 |
| `packages/cli/src/utils/installationInfo.ts:27` | 宿主 Node:npm CLI 路径解析默认值,并非仅展示信息;与 npm 消费方一同保留。 |
| `packages/cli/src/utils/managed-npm-update.ts:118,120,137,188,329,331` | 宿主 Node:npm 配置/路径解析(118/120/137)、暂存 CLI 健康启动(188)、npm 安装及路径查找(329/331);保留宿主身份,逐阶段验证(S3)。 |
| `packages/cli/src/utils/processUtils.ts:68,69` | 启动器:execve 可执行文件及 argv[0];尚未迁移编译应用形态(S4)。 |
| `packages/cli/src/utils/relaunch.ts:167,168,190` | 启动器:execve 可执行文件/argv[0](167/168)、监督式 IPC spawn 回退(190);尚未迁移编译应用形态(S4)。 |
| `packages/core/src/agents/arena/ArenaManager.ts:1096` | 启动器:agent 工作树中的 argv[1];未迁移(S4)。 |
| `packages/core/src/hooks/hook-command-cgroup.ts:166` | 解释器:CommonJS eval cgroup 启动器;未迁移(S3、Linux cgroup v2)。 |
| `packages/core/src/sandbox/bwrap-execution.ts:41` | 解释器:JS relay 的 realpath 暂存;未迁移(S3、Linux bubblewrap)。 |
| `packages/core/src/sandbox/landlock-execution.ts:113` | 解释器:JS relay 的 realpath 暂存;未迁移(S3、Linux landlock)。 |
| `packages/core/src/utils/runtime-info.ts:175` | 解释器解析器回退:manifest 缺失/拒绝时使用宿主可执行文件;S1 已实现,不支持编译形态。 |
| `packages/mobile-mcp/src/server.ts:189` | 信息:遥测身份哈希输入;不启动子进程,有意排除于迁移范围。 |
| `packages/node-repl/src/desktop-relay/cli.ts:89,174,192` | 混合:npm 宿主解释器(89);持久 launchd relay 应用入口(174,S4 待迁移);权限提示文本(192,信息);不可盲目统一替换。 |
| `packages/node-repl/src/desktop-relay/runtime.ts:53` | 解释器:node-repl 包入口,并非 Qwen CLI 启动器;未迁移(S3),须保留包/运行时边界。 |
| `packages/node-repl/src/kernel-manager.ts:567` | 解释器:带实验参数和 fd3/fd4 协议的 VM kernel;属宿主 Node 契约,不自动换为 Bun(S3 兼容性待验证)。 |
| `packages/sdk-typescript/src/utils/cliPath.ts:313,340` | 启动器:捆绑 CLI 默认值(313);解释器:显式 JS 可执行文件(340)。使用 SDK 宿主身份,有意不强制私有 Bun;编译形态处理待 S4。 |
| `packages/vscode-ide-companion/src/extension.ts:409` | 启动器:扩展宿主 CLI 终端命令;保留 Electron/VSCode 环境契约,不强制私有 Bun(S4 待迁移)。 |
| `packages/vscode-ide-companion/src/services/acpConnection.ts:109` | 启动器:扩展宿主 ACP 入口;保留 Electron run-as-Node 契约(S4 待迁移)。 |
| `packages/vscode-ide-companion/src/services/qwenDaemonProcess.ts:76` | 启动器:扩展宿主 serve 入口;保留 Electron run-as-Node 契约(S4 待迁移)。 |
| `scripts/cli-entry.js:476,488` | 启动器:Bun/Windows 的 `spawnSync(--expose-gc, cliPath)` 分支及其错误文案;未迁移编译形态(S4),不是解释器选择器接线。 |
| `scripts/dev.js:237` | 开发宿主解释器选择;有意保留开发工具身份,非 standalone 生产入口迁移。 |
范围例外:`packages/sdk-typescript/src/transport/ProcessTransport.ts` 的字面命中仅在注释中,实际通过 `forkOptions.execPath` 消费 `cliPath.ts` 的选择,须与 SDK/Electron 契约共同验证;`channel-worker-supervisor.ts:196,271,280` 的 `execPath` 参数消费对应本表的 1119 调用。`core/src/utils/runtime-info.ts` 的其他字面命中均为注释。
有意排除生产迁移:`packages/cli/src/serve/cdp-tunnel/acceptance/run-real-chrome.mjs:95,112,129` 是验收驱动;`packages/cli/src/serve/managed-runtime-tool-v3-host.test-helper.ts:44` 是测试 helper;所有测试/夹具及 `packages/sdk-java/**/src/test/**` 是宿主测试。`packages/**/scripts/**`、包根的 `build.mjs` 和 `scripts/**` 中其余命中属于构建、打包、开发、benchmark、sandbox prototype、lint 或 E2E 驱动,保留各自工具宿主;不把它们算作已迁移的应用调用点。`scripts/build-standalone-release.js` 的脚本 spawn 是构建宿主行为,不证明生成归档已能运行。打包脚本中的运行时字符串是布局配置,另走原样归档验收。
S1 已接入选择器的三处是 `core/src/code-mode/host-client.ts`、`core/src/sandbox/file-worker-client.ts`、`core/src/hooks/hookRunner.ts`(均加 `packages/` 前缀);它们不再直接读取 `process.execPath`,不能因本字面扫描未命中而漏记。`getPty.ts` 是运行时检测,不是解释器 spawn。S3/S4 是待办范围标识,不表示这些迁移已提交或获准;尤其 kernel、npm、SDK 与扩展宿主不能统一替换为私有 Bun。
### SDK 消费方
`@qwen-code/qwen-code-core` 作为库使用(SDK 宿主)时运行在宿主自身运行时下。SDK 代码路径不得向 Node 宿主强加 Bun 解释器:只有包布局中存在 standalone `manifest.json` 时 `getJsInterpreterCommand` 才会偏离 `process.execPath`,npm 与源码安装都没有该文件。
## 接口约定
### 1. App launcher
- `scripts/cli-entry.js` 是 `QWEN_CODE_CLI` 的唯一写入方;`getShellContextEnvVars` 负责传播。自调用在 shell 中用 `"${QWEN_CODE_CLI:-qwen}"`,直接 spawn 用写入的值。
- 同进程树内再唤起应用(relaunch、managed worker、arena agent)保留同解释器回退 `process.execPath + process.argv[1]`,因为所有已发布布局中入口都是 JS 文件。编译产物形态必须先通过 `QWEN_CODE_CLI` 发布其 launcher,上述位置才能变更;上面清单仅固定源码位置,不代表已验证编译应用行为。
### 2. JS interpreter —— `packages/core/src/utils/runtime-info.ts`
- `isBunRuntime(): boolean` —— 唯一的运行时检测点。
- `getJsInterpreterCommand(importMetaUrl): { command, runtime }` —— 解析任意 JS 子进程使用的解释器:
1. 唯一可信位置是 **standalone 包根**:调用方 bundle 目录(`resolveBundleDir`)的父目录,必须同时存在 `manifest.json` 与 standalone 入口标记 `lib/cli-entry.js`。**不做向上遍历** —— 无关祖先清单(例如 npm 宿主工程的构建 manifest)永远不会被读取,npm、源码与 SDK 布局因此始终使用 `process.execPath`。
2. 在该可信包根内,所有声明先校验后使用:`runtime` 必须恰为 `'bun'` 或 `'node'`;解释器 —— `interpreter` 字段相对包根解析(绝对路径原样采用,不强制限制在包根内),否则取标准位置 `bun/bin/bun` / `node/bin/node` —— 必须是已存在的普通文件。POSIX 还要求执行权限;Windows 只检查普通文件存在,不验证 PE 格式或实际可启动性,默认路径为 `bun/bun.exe` / `node/node.exe`。
3. 任何无效或缺失的声明 —— 非对象清单、未知 `runtime`、非字符串 `interpreter`、二进制缺失、目录、POSIX 不可执行文件、入口标记缺失 —— 一律回退 `process.execPath`(运行当前进程的解释器:宿主 Node、内置 Node,或 standalone Bun 形态的私有内置 Bun)。解析绝不猜测其他二进制,也绝不抛出异常。
- `runtime` 是当前没有生产读点的元数据:三处已迁移调用方仅消费 `command`。按运行时区分的消费逻辑(包括未来 S3 对该字段的任何用途)尚未在此实现。
- 结果按进程记忆:首次调用方的 URL 决定解析结果,后续调用方 URL 被忽略;当前生产调用方共享一个安装根。`resetJsInterpreterCacheForTests()` 清除缓存。
- 沙箱 file-worker 选择此解释器,但 bwrap/landlock 的 allowlist 仍暂存 `realpathSync(process.execPath)`。这项衔接仍是尚未实现的 S3 迁移;本 PR 不证明解析出的解释器与暂存二进制不同时的布局已受支持。
- 支持范围:**基于解释器的 Node/Bun 分发**。拒绝声明时返回 `process.execPath`,它在编译形态下会是应用二进制。本 PR 不支持编译应用,也没有提供编译制品生产路径。未来该形态必须显式打包解释器并独立验证失败行为后才能采用;当前回退不保证在编译形态下得到可用解释器。
迁移规则:任何新增的、以子进程方式运行 JS 文件或 `-e` 片段的代码必须走 `getJsInterpreterCommand`;再唤起应用的位置走 `QWEN_CODE_CLI` / 入口回退。
## 跨运行时测试覆盖
`packages/core/src/utils/runtime-boundary.node-bun.test.ts` 在 Node 和可用的
Bun 解释器下运行同一 fixture,覆盖身份与 stdio JSON 行、退出码传播、POSIX
SIGTERM 处理、显式 shutdown 的 IPC 往返、`--expose-gc` 和
`--input-type=commonjs --eval`。这些是测试契约,不代表全部平台或打包制品已经通过。
Windows 跳过可捕获 SIGTERM 用例;缺少 Bun 二进制时跳过 Bun 腿。报告执行结果时必须
记录实际运行时版本与 skip。交付 Bun 1.3.14 固定版本及 standalone 制品仍需独立打包验收。
此 fixture 不探测 `process.execve`、原生 PTY 加载、fd-3 状态管道或进程组信号。
重新启动位置的 `process.execve` 特性检测和 `getPty` 的 Bun 门禁属于源码机制,
不是本矩阵的实测结果。原生 PTY 与进程树行为需要各自真实进程和制品证据。
## 验证计划
- `packages/core/src/utils/runtime-info.test.ts` —— manifest 解析、回退、记忆化。
- `packages/core/src/utils/runtime-boundary.node-bun.test.ts` —— 上述同一 fixture 覆盖;无 Bun 二进制时 Bun 腿跳过,跳过不代表兼容性通过。
- S2/S3 以上述清单表为迁移检查清单;S5 按 MYWO-8 验收协议在真实归档上重跑矩阵。
## 遗留问题
- 未来编译形态下 `ArenaManager` / managed-worker 重启:编译包必须发布 launcher(`QWEN_CODE_CLI`),上列同解释器回退位置需要 launcher 形态的迁移。已列入 S4 跟踪。
- `mem0-settings.ts` 的身份判断与 `command === process.execPath` 耦合;S3 中与其 spawn 位置一起迁移,避免身份与 launcher 漂移。

View file

@ -11,6 +11,7 @@ import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { CodeModeBindingPlan } from '../tools/code-mode.js';
import { resolveBundleDir } from '../utils/bundlePaths.js';
import { getJsInterpreterCommand } from '../utils/runtime-info.js';
import {
CodeModeTurnTerminated,
type CodeModeToolResult,
@ -50,10 +51,11 @@ export class CodeModeExecutionError extends Error {
function hostCommand(): { command: string; args: string[] } {
const currentFile = fileURLToPath(import.meta.url);
const { command } = getJsInterpreterCommand(import.meta.url);
if (currentFile.endsWith('.ts')) {
const require = createRequire(import.meta.url);
return {
command: process.execPath,
command,
args: [
'--import',
pathToFileURL(require.resolve('tsx')).href,
@ -63,10 +65,10 @@ function hostCommand(): { command: string; args: string[] } {
}
const sibling = path.join(path.dirname(currentFile), 'host.js');
if (existsSync(sibling)) {
return { command: process.execPath, args: [sibling] };
return { command, args: [sibling] };
}
return {
command: process.execPath,
command,
args: [path.join(resolveBundleDir(import.meta.url), 'codeModeHost.js')],
};
}

View file

@ -36,6 +36,7 @@ import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js';
import type { Config } from '../config/config.js';
import { getShellContextEnvVars } from '../services/shellContextEnv.js';
import { sanitizeChildEnv } from '../utils/sanitize-child-env.js';
import { getJsInterpreterCommand } from '../utils/runtime-info.js';
import {
HookCommandCgroup,
HookCommandIsolationUnavailableError,
@ -1254,7 +1255,7 @@ export class HookRunner {
delete supervisorEnv['NODE_OPTIONS'];
try {
child = spawn(
process.execPath,
getJsInterpreterCommand(import.meta.url).command,
[
'--input-type=commonjs',
'--eval',

View file

@ -9,6 +9,7 @@ import {
sandboxAsset,
type ExecutionSandboxPolicy,
} from './sandbox-execution.js';
import { getJsInterpreterCommand } from '../utils/runtime-info.js';
import {
encodeSandboxWriteRequest,
MAX_FILE_HEADER_BYTES,
@ -35,7 +36,9 @@ export async function writeSandboxFile(
const handle = await executeSandbox(
policy,
{
executable: process.execPath,
// S3 handoff: bwrap/landlock still stage process.execPath, not this
// resolver. Allowlist migration is pending; see bun-runtime-boundary.md.
executable: getJsInterpreterCommand(import.meta.url).command,
args: [sandboxAsset('file-worker')],
cwd: policy.workspace,
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },

View file

@ -5,6 +5,7 @@
*/
import { getErrorMessage } from './errors.js';
import { isBunRuntime } from './runtime-info.js';
export type PtyImplementation = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -42,7 +43,7 @@ export interface PtyLoadResult {
*/
export const loadPty = async (): Promise<PtyLoadResult> => {
// Bun can load @lydell/node-pty, but it hangs under Desktop's runtime.
if ('bun' in process.versions) {
if (isBunRuntime()) {
return {
impl: null,
loadError:

View file

@ -0,0 +1,292 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Same-fixture cross-runtime regression for the runtime boundary.
*
* One fixture script is spawned as a child under BOTH the Node interpreter
* and Bun (when a `bun` binary is available), and the observable child
* behaviour is asserted to match: stdio JSON framing, exit-code propagation,
* SIGTERM handling, the Node IPC channel, and the interpreter flags the
* entry/worker paths rely on (`--expose-gc`, `--input-type=commonjs --eval`).
*
* The suite itself runs under vitest/Node; the Bun leg simply spawns the
* `bun` binary as a child, so no Bun test-runner migration is involved.
* Without Bun on PATH (or with BUN_TEST_BIN pointing at nothing) the Bun leg
* is skipped rather than failing — pinning the *shipped* Bun version belongs
* to the standalone packaging acceptance, not this regression.
*/
import { once } from 'node:events';
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
// argv[2] contract: 'exit' (default) exits 0 after printing the identity
// line, 'exit:<code>' exits with that code, 'stay' keeps the event loop
// alive (for signal tests). A child with an IPC channel always stays alive.
const fixtureSource = `
process.on('SIGTERM', () => {
process.stdout.write(JSON.stringify({ got: 'SIGTERM' }) + '\\n');
process.exit(0);
});
const runtime = process.isBun ? 'bun' : 'node';
process.stdout.write(
JSON.stringify({
runtime,
bun: process.versions.bun ?? null,
node: process.versions.node ?? null,
hasSend: typeof process.send === 'function',
gcType: typeof globalThis.gc,
}) + '\\n',
);
if (typeof process.send === 'function') {
process.on('message', (message) => {
if (message && message.type === 'shutdown') process.exit(0);
if (message && message.type === 'ping') {
process.send({ type: 'pong', saw: message.type });
}
});
process.send({ type: 'ready' });
}
const mode = process.argv[2] ?? 'exit';
if (mode === 'stay' || typeof process.send === 'function') {
setInterval(() => {}, 1000);
} else {
process.exit(Number(mode.replace('exit:', '')) || 0);
}
`;
const evalFixtureSource = `console.log(JSON.stringify({ eval: true, runtime: process.isBun ? 'bun' : 'node' }));`;
describe('runtime boundary: same fixture under node and bun', () => {
let tempRoot: string;
let fixturePath: string;
const bunBin = process.env['BUN_TEST_BIN'] ?? 'bun';
const bunVersionResult = spawnSync(bunBin, ['--version'], {
encoding: 'utf8',
timeout: 15_000,
});
const bunAvailable = bunVersionResult.status === 0;
const bunVersion = bunVersionResult.stdout?.trim() ?? '';
const runtimes: Array<'node' | 'bun'> = ['node'];
if (bunAvailable) runtimes.push('bun');
const interpreterFor = (runtime: 'node' | 'bun'): string =>
runtime === 'bun'
? bunBin
: (process.env['NODE_TEST_BIN'] ?? process.execPath);
const waitForClose = (
child: ReturnType<typeof spawn>,
timeout = 5000,
): Promise<[number | null, string | null]> => {
const onError = (error: Error) => child.emit('error', error);
child.stdout?.on('error', onError);
child.stderr?.on('error', onError);
return once(child, 'close', { signal: AbortSignal.timeout(timeout) })
.then(([code, signal]): [number | null, string | null] => [code, signal])
.finally(() => {
child.stdout?.off('error', onError);
child.stderr?.off('error', onError);
});
};
const cleanupChild = async (
child: ReturnType<typeof spawn>,
): Promise<void> => {
try {
if (
child.pid !== undefined &&
child.exitCode === null &&
child.signalCode === null
) {
const closed = waitForClose(child, 2000);
child.kill('SIGKILL');
await closed;
}
} finally {
child.stdin?.destroy();
child.stdout?.destroy();
child.stderr?.destroy();
if (child.connected) child.disconnect();
}
};
const collectIdentityLines = async (
child: ReturnType<typeof spawn>,
): Promise<string[]> => {
const lines: string[] = [];
let pending = '';
child.stdout!.setEncoding('utf8');
return new Promise((resolve, reject) => {
const done = (error?: Error) => {
clearTimeout(timer);
child.off('error', onError);
child.stdout!.off('error', onError);
child.stderr!.off('error', onError);
child.stdout!.off('close', onClose);
if (error) reject(error);
else resolve(lines);
};
const onError = (error: Error) => done(error);
const onClose = () =>
done(new Error('Child closed before identity line'));
const timer = setTimeout(
() => done(new Error('Timed out waiting for child identity')),
5000,
);
child.on('error', onError);
child.stdout!.on('error', onError);
child.stderr!.on('error', onError);
child.stdout!.once('close', onClose);
child.stdout!.on('data', (chunk: string) => {
const complete = (pending + chunk).split('\n');
pending = complete.pop() ?? '';
for (const line of complete) {
if (line.length > 0) lines.push(line);
}
if (lines.length > 0) done();
});
});
};
beforeEach(() => {
tempRoot = mkdtempSync(path.join(tmpdir(), 'runtime-boundary-test-'));
fixturePath = path.join(tempRoot, 'fixture.js');
writeFileSync(fixturePath, fixtureSource);
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
it.each(runtimes)(
'%s: reports runtime identity over the stdio protocol',
{ timeout: 30_000 },
(runtime) => {
const result = spawnSync(interpreterFor(runtime), [fixturePath], {
encoding: 'utf8',
timeout: 30_000,
});
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout.trim()) as {
runtime: string;
bun: string | null;
};
expect(parsed.runtime).toBe(runtime);
if (runtime === 'bun') expect(parsed.bun).toBeTypeOf('string');
},
);
it.each(runtimes)(
'%s: propagates child exit codes',
{ timeout: 30_000 },
(runtime) => {
const result = spawnSync(
interpreterFor(runtime),
[fixturePath, 'exit:7'],
{ encoding: 'utf8', timeout: 30_000 },
);
expect(result.status).toBe(7);
},
);
it.each(runtimes)(
'%s: delivers SIGTERM to a handler and exits gracefully',
{ skip: process.platform === 'win32', timeout: 30_000 },
async (runtime) => {
const child = spawn(interpreterFor(runtime), [fixturePath, 'stay'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const lines = await collectIdentityLines(child);
const closed = waitForClose(child);
child.kill('SIGTERM');
const [exitCode, signal] = await closed;
const termLine = lines.find((line) => line.includes('SIGTERM'));
expect(termLine).toBeDefined();
expect(JSON.parse(termLine!)).toEqual({ got: 'SIGTERM' });
expect(exitCode).toBe(0);
expect(signal).toBeNull();
} finally {
await cleanupChild(child);
}
},
);
it.each(runtimes)(
'%s: round-trips messages over the Node IPC channel',
{ timeout: 30_000 },
async (runtime) => {
const child = spawn(interpreterFor(runtime), [fixturePath, 'stay'], {
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
try {
const types: string[] = [];
const closed = waitForClose(child);
child.on('message', (message) => {
const type = (message as { type?: string }).type ?? '';
types.push(type);
if (type === 'ready') child.send({ type: 'ping' });
if (type === 'pong') child.send({ type: 'shutdown' });
});
const [exitCode, signal] = await closed;
expect(types).toEqual(['ready', 'pong']);
expect(exitCode).toBe(0);
expect(signal).toBeNull();
} finally {
await cleanupChild(child);
}
},
);
it.each(runtimes)(
'%s: exposes gc under --expose-gc (entry child contract)',
{ timeout: 30_000 },
(runtime) => {
const result = spawnSync(
interpreterFor(runtime),
['--expose-gc', fixturePath],
{ encoding: 'utf8', timeout: 30_000 },
);
expect(result.status).toBe(0);
const identity = JSON.parse(result.stdout.trim()) as { gcType: string };
expect(identity.gcType).toBe('function');
},
);
it.each(runtimes)(
'%s: runs --input-type=commonjs --eval (hook supervisor contract)',
{ timeout: 30_000 },
(runtime) => {
const result = spawnSync(
interpreterFor(runtime),
['--input-type=commonjs', '--eval', evalFixtureSource],
{ encoding: 'utf8', timeout: 30_000 },
);
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout.trim()) as {
eval: boolean;
runtime: string;
};
expect(parsed).toEqual({ eval: true, runtime });
},
);
it.skipIf(!bunAvailable)(
`bun leg records interpreter version: ${bunVersion || 'unavailable'}`,
{ timeout: 30_000 },
() => {
expect(bunVersion).toMatch(/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/);
process.stdout.write(
`[runtime-boundary] bun leg ran with bun ${bunVersion}\n`,
);
},
);
});

View file

@ -0,0 +1,313 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
getJsInterpreterCommand,
isBunRuntime,
resetJsInterpreterCacheForTests,
} from './runtime-info.js';
describe('isBunRuntime', () => {
it('reports the current process runtime', () => {
// The unit suite runs under Node (repo tooling stays on Node this phase).
expect(isBunRuntime()).toBe(typeof process.versions['bun'] === 'string');
expect(isBunRuntime()).toBe(false);
});
});
describe('getJsInterpreterCommand', () => {
let tempRoot: string;
const interpreterPath = (root: string, runtime: 'bun' | 'node'): string =>
process.platform === 'win32'
? path.join(root, runtime, `${runtime}.exe`)
: path.join(root, runtime, 'bin', runtime);
const writeExecutable = (filePath: string, executable = true): void => {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, '#!/bin/sh\n');
chmodSync(filePath, executable ? 0o755 : 0o644);
};
// A standalone install root: manifest + entry marker + interpreter.
// With `marker: false` it deliberately omits `lib/cli-entry.js` so the
// trust-anchor rejection can be observed in isolation.
const writeStandaloneLayout = (options: {
manifest: Record<string, unknown>;
withBun?: boolean;
withNode?: boolean;
interpreterTarget?: 'non-executable';
marker?: boolean;
}): string => {
const root = mkdtempSync(path.join(tempRoot, 'pkg-'));
writeFileSync(
path.join(root, 'manifest.json'),
JSON.stringify(options.manifest),
);
for (const runtime of ['bun', 'node'] as const) {
if (runtime === 'bun' ? options.withBun : options.withNode) {
writeExecutable(
interpreterPath(root, runtime),
options.interpreterTarget !== 'non-executable',
);
}
}
if (options.marker !== false) {
mkdirSync(path.join(root, 'lib'), { recursive: true });
writeFileSync(path.join(root, 'lib', 'cli-entry.js'), 'export {};\n');
}
return root;
};
const resolvedFor = (root: string, modulePath: string): unknown =>
getJsInterpreterCommand(pathToFileURL(path.join(root, modulePath)).href);
beforeEach(() => {
tempRoot = mkdtempSync(path.join(tmpdir(), 'runtime-info-test-'));
resetJsInterpreterCacheForTests();
});
afterEach(() => {
resetJsInterpreterCacheForTests();
rmSync(tempRoot, { recursive: true, force: true });
});
const execPathFallback = (): {
command: string;
runtime: 'node' | 'bun';
} => ({
command: process.execPath,
runtime: isBunRuntime() ? 'bun' : 'node',
});
it('falls back to process.execPath when no standalone manifest exists', () => {
const moduleDir = mkdtempSync(path.join(tempRoot, 'plain-'));
const modulePath = path.join(moduleDir, 'worker.js');
writeFileSync(modulePath, 'export {};\n');
expect(resolvedFor(path.dirname(modulePath), 'worker.js')).toEqual(
execPathFallback(),
);
});
it('resolves the bundled Bun interpreter from a trusted bun-flavor layout', () => {
const root = writeStandaloneLayout({
manifest: { runtime: 'bun' },
withBun: true,
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual({
command: interpreterPath(root, 'bun'),
runtime: 'bun',
});
});
it('resolves the bundled Node interpreter from a trusted node-flavor layout', () => {
const root = writeStandaloneLayout({
manifest: { runtime: 'node' },
withNode: true,
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual({
command: interpreterPath(root, 'node'),
runtime: 'node',
});
});
it('honours an explicit interpreter field relative to the package root', () => {
const root = writeStandaloneLayout({
manifest: {
runtime: 'bun',
interpreter:
process.platform === 'win32' ? './bun/bun.exe' : './bun/bin/bun',
},
withBun: true,
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual({
command: interpreterPath(root, 'bun'),
runtime: 'bun',
});
});
it('ignores an unrelated ancestor manifest in an npm bundle layout', () => {
// F1 regression: a real bundled install reads chunks from the npm
// package root, so the only manifest one level above the bundle dir is
// `node_modules/@qwen-code/manifest.json` — never a host project's
// manifest higher up. Place a host manifest that even CLAIMS a valid
// runtime and interpreter; the host build manifest named in it must
// never be executed.
const host = mkdtempSync(path.join(tempRoot, 'host-'));
const runner = path.join(host, 'custom-runner');
writeFileSync(runner, '#!/bin/sh\necho UNRELATED_EXECUTABLE_INVOKED\n');
chmodSync(runner, 0o755);
mkdirSync(path.join(host, 'lib'), { recursive: true });
writeFileSync(path.join(host, 'lib', 'cli-entry.js'), 'export {};\n');
writeFileSync(
path.join(host, 'manifest.json'),
JSON.stringify({
runtime: 'bun',
name: 'unrelated-sdk-host',
interpreter: './custom-runner',
}),
);
writeExecutable(
path.join(
host,
'node_modules',
'@qwen-code',
'qwen-code',
'bun',
'bin',
'bun',
),
);
expect(
resolvedFor(
path.join(host, 'node_modules', '@qwen-code', 'qwen-code'),
'chunks/worker.js',
),
).toEqual(execPathFallback());
});
it('ignores an unrelated ancestor manifest for a deep SDK module layout', () => {
const host = mkdtempSync(path.join(tempRoot, 'sdk-host-'));
writeFileSync(
path.join(host, 'manifest.json'),
JSON.stringify({ runtime: 'bun' }),
);
writeExecutable(interpreterPath(host, 'bun'));
mkdirSync(path.join(host, 'lib'), { recursive: true });
writeFileSync(path.join(host, 'lib', 'cli-entry.js'), 'export {};\n');
const packageDir = path.join(
host,
'node_modules',
'@qwen-code',
'qwen-code-core',
);
mkdirSync(packageDir, { recursive: true });
writeFileSync(path.join(packageDir, 'package.json'), '{"name":"x"}\n');
expect(
resolvedFor(
packageDir,
path.join('dist', 'src', 'code-mode', 'host-client.js'),
),
).toEqual(execPathFallback());
});
it.each([
['object interpreter', { runtime: 'bun', interpreter: { path: 'x' } }],
['number interpreter', { runtime: 'bun', interpreter: 42 }],
])('falls back on a non-string %s', (_name, manifest) => {
const root = writeStandaloneLayout({ manifest, withBun: true });
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
});
it('falls back when the declared interpreter is a directory', () => {
const root = writeStandaloneLayout({
manifest: {
runtime: 'bun',
interpreter: process.platform === 'win32' ? './bun' : './bun/bin',
},
withBun: true,
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
});
it.each(['bun', 'node'] as const)(
'falls back when the declared %s interpreter is not executable',
{ skip: process.platform === 'win32' },
(runtime) => {
const root = writeStandaloneLayout({
manifest: { runtime },
withBun: true,
withNode: true,
interpreterTarget: 'non-executable',
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
},
);
it('falls back when the standard interpreter binary is absent', () => {
const root = writeStandaloneLayout({
manifest: { runtime: 'bun' },
withBun: false,
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
});
it('falls back on an unknown runtime even with a valid interpreter', () => {
const root = writeStandaloneLayout({
manifest: { runtime: 'deno', interpreter: './bun/bin/bun' },
withBun: true,
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
});
it('falls back when the standalone entry marker is missing', () => {
const root = writeStandaloneLayout({
manifest: { runtime: 'bun' },
withBun: true,
marker: false,
});
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
});
it('ignores a manifest that is not a JSON object', () => {
const root = mkdtempSync(path.join(tempRoot, 'scalar-'));
writeFileSync(path.join(root, 'manifest.json'), '"just-a-string"');
mkdirSync(path.join(root, 'lib'), { recursive: true });
writeFileSync(path.join(root, 'lib', 'cli-entry.js'), 'export {};\n');
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
});
it.each(['{', '{"runtime":"bun",', ''])(
'falls back on malformed manifest %j',
(contents) => {
const root = writeStandaloneLayout({
manifest: { runtime: 'bun' },
withBun: true,
});
writeFileSync(path.join(root, 'manifest.json'), contents);
expect(() => resolvedFor(root, 'lib/worker.js')).not.toThrow();
expect(resolvedFor(root, 'lib/worker.js')).toEqual(execPathFallback());
},
);
it.each(['bun', 'node'] as const)(
'resolves %s from a standalone lib/chunks entry',
(runtime) => {
const root = writeStandaloneLayout({
manifest: { runtime },
withBun: true,
withNode: true,
});
expect(resolvedFor(root, 'lib/chunks/worker.js')).toEqual({
command: interpreterPath(root, runtime),
runtime,
});
},
);
it('memoizes the resolution for the process lifetime', () => {
const root = writeStandaloneLayout({
manifest: { runtime: 'bun' },
withBun: true,
});
const first = resolvedFor(root, 'lib/worker.js');
const second = getJsInterpreterCommand(
pathToFileURL(path.join(tempRoot, 'unrelated.js')).href,
);
expect(second).toEqual(first);
});
});

View file

@ -0,0 +1,192 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveBundleDir } from './bundlePaths.js';
/**
* Runtime boundary helpers for child processes.
*
* Two distinct identities get conflated when code reaches for
* `process.execPath`:
*
* - the **app launcher**: how a subprocess reaches THIS build of the CLI
* (the standalone shim `bin/qwen`, the npm bin entry, or
* `interpreter cli.js`). It is already published as `QWEN_CODE_CLI` by
* `scripts/cli-entry.js` and propagated to shell subprocesses via
* `getShellContextEnvVars`.
*
* - the **JS interpreter**: the binary that runs an arbitrary JavaScript
* file or `-e` snippet as a child (bundle workers, hook supervisors,
* sandbox file workers, the code-mode host). Today that is always the
* interpreter running this process, so `process.execPath` happens to be
* correct. Under a future compiled-app flavor (`bun build --compile`)
* `process.execPath` becomes the application binary, which cannot run
* arbitrary JS files — silently reusing it for workers would break every
* JS child at once.
*
* Spawn sites for arbitrary-JS children must go through
* {@link getJsInterpreterCommand} instead of reading `process.execPath`
* directly, so the compiled flavor only has to change the resolution in this
* one file. Spawn sites that re-invoke the app itself must prefer
* `QWEN_CODE_CLI` (see the design doc `docs/design/bun-runtime-boundary.md`)
* and keep `process.execPath` + `process.argv[1]` only as a fallback.
*/
export type JsRuntime = 'node' | 'bun';
export interface JsInterpreterCommand {
/** Absolute path of a binary that can run arbitrary JS files. */
command: string;
/**
* Runtime metadata; current production callers consume only `command`.
* Runtime-specific consumer behavior is not implemented here.
*/
runtime: JsRuntime;
}
/**
* Whether this process itself runs under Bun.
*
* Single detection point for runtime gating (renderer selection, PTY
* availability, Bun-specific feature detection). Kept as a function so tests
* can document the check in one place.
*/
export function isBunRuntime(): boolean {
return typeof process.versions['bun'] === 'string';
}
const STANDALONE_ENTRY_MARKER = 'lib/cli-entry.js';
interface StandaloneManifest {
runtime?: unknown;
interpreter?: unknown;
}
/**
* A manifest counts as a standalone runtime manifest only when it is a JSON
* object whose `runtime` is exactly `'bun'` or `'node'`. Anything else —
* arrays, primitives, objects with a different or missing `runtime` — is not
* ours, and the caller falls back to `process.execPath` instead of guessing.
*/
function parseStandaloneManifest(
manifestPath: string,
): { runtime: JsRuntime; interpreter?: string } | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return undefined;
}
const manifest = parsed as StandaloneManifest;
if (manifest.runtime !== 'bun' && manifest.runtime !== 'node') {
return undefined;
}
if (
manifest.interpreter !== undefined &&
typeof manifest.interpreter !== 'string'
) {
return undefined;
}
return {
runtime: manifest.runtime,
interpreter: manifest.interpreter as string | undefined,
};
}
/**
* Executable regular file: a directory (or anything non-file) and a missing
* executable bit on POSIX both reject, so a broken declaration can never
* reach spawn as EACCES.
*/
function isExecutableFile(candidate: string): boolean {
try {
if (!fs.statSync(candidate).isFile()) return false;
if (process.platform === 'win32') return true;
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function interpreterPathForRuntime(
packageRoot: string,
runtime: JsRuntime,
): string {
// Layout of the standalone archives (see scripts/create-standalone-package.js):
// `<root>/manifest.json`, `<root>/bun/bin/bun` or `<root>/node/bin/node`,
// `<root>/lib/cli-entry.js`.
const segments =
process.platform === 'win32'
? { bun: ['bun', 'bun.exe'], node: ['node', 'node.exe'] }
: { bun: ['bun', 'bin', 'bun'], node: ['node', 'bin', 'node'] };
return path.join(packageRoot, ...segments[runtime]);
}
let cachedInterpreter: JsInterpreterCommand | undefined;
/**
* Resolves how to spawn an arbitrary-JS child with the runtime this build
* ships with.
*
* Resolution is anchored to the caller's own installation: the only trusted
* location is the standalone package root — the parent of the caller's
* bundle directory, holding BOTH a valid `manifest.json` and the standalone
* entry marker `lib/cli-entry.js`. There is no upward traversal, so an
* unrelated ancestor `manifest.json` (an npm host project's build manifest,
* for example) can never be picked up; npm, source and SDK layouts always
* keep `process.execPath`.
*
* Within that trusted root, every declaration is validated before use:
* `runtime` must be exactly `'bun'` or `'node'`, and the interpreter — the
* `interpreter` field resolved against the package root (absolute paths are
* used as-is, without containment), else the standard
* `bun/bin/bun` / `node/bin/node` location — must be an existing executable
* regular file. Any invalid or missing declaration falls back to
* `process.execPath` rather than guessing at another binary.
*
* The result is memoized per process: only the first call's `import.meta.url`
* is used; later caller URLs are ignored. Current production callers share
* one installation root. Pass the caller URL so chunked bundle layouts
* resolve like sibling assets on that first call.
*/
export function getJsInterpreterCommand(
importMetaUrl: string,
): JsInterpreterCommand {
if (cachedInterpreter) return cachedInterpreter;
const packageRoot = path.dirname(resolveBundleDir(importMetaUrl));
const manifestPath = path.join(packageRoot, 'manifest.json');
const manifest = parseStandaloneManifest(manifestPath);
if (
manifest &&
fs.existsSync(path.join(packageRoot, ...STANDALONE_ENTRY_MARKER.split('/')))
) {
const candidate = manifest.interpreter
? path.resolve(packageRoot, manifest.interpreter)
: interpreterPathForRuntime(packageRoot, manifest.runtime);
if (isExecutableFile(candidate)) {
cachedInterpreter = { command: candidate, runtime: manifest.runtime };
return cachedInterpreter;
}
}
cachedInterpreter = {
command: process.execPath,
runtime: isBunRuntime() ? 'bun' : 'node',
};
return cachedInterpreter;
}
/**
* Test-only: clears the memoized interpreter resolution.
*/
export function resetJsInterpreterCacheForTests(): void {
cachedInterpreter = undefined;
}