mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-23 23:39:45 +00:00
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:
parent
9280ef9845
commit
2eab3cc431
9 changed files with 1222 additions and 6 deletions
246
docs/design/bun-runtime-boundary.md
Normal file
246
docs/design/bun-runtime-boundary.md
Normal 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.
|
||||
166
docs/design/bun-runtime-boundary.zh-CN.md
Normal file
166
docs/design/bun-runtime-boundary.zh-CN.md
Normal 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 漂移。
|
||||
|
|
@ -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')],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
292
packages/core/src/utils/runtime-boundary.node-bun.test.ts
Normal file
292
packages/core/src/utils/runtime-boundary.node-bun.test.ts
Normal 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`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
313
packages/core/src/utils/runtime-info.test.ts
Normal file
313
packages/core/src/utils/runtime-info.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
192
packages/core/src/utils/runtime-info.ts
Normal file
192
packages/core/src/utils/runtime-info.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue