feat(cli): ship private Bun and preserve interpreter boundaries (#14959)

* fix(cli): restore bounded OpenTUI dialogs and add migration gates

* fix(cli): preserve runtime boundaries during standalone updates

Preserve Node/Bun archive flavor and validate actual CLI loading, package
identity, version and prepared installation identity before activation.
Keep Node npm transport and restrict Bun registry lookup to trusted config.

Integrates chiga0's update stack through ce67edaba5,
with isolated fixtures and real Node/Bun runtime regression coverage.

Co-authored-by: gary.gq <[email protected]>

* fix(cli): preserve OpenTUI input and attachment ownership

* test(cli): bound cancellation test waits and cleanup

* feat(cli): ship private Bun for opt-in npm OpenTUI sessions

* feat(cli): align OpenTUI status lines with Ink

* fix(cli): align OpenTUI editor and cancellation transactions

* fix(tui): harden migration cleanup and evidence after review

* test(tui): fail acceptance on unhandled errors

* fix(cli): validate standalone launchers and registry settings

* docs(cli): clarify consolidated OpenTUI delivery status

* test(cli): isolate system settings in TUI parity fixtures

* test(cli): isolate compile cache in standalone runtime checks

* test(cli): reduce standalone runtime fixture compression cost

* fix(cli): settle OpenTUI cancellation when replay resets a session

* feat(cli): align OpenTUI Vim, history and session output

Reuse the shared editing and history contracts, preserve cancellation ownership, and connect accepted interactive turns to the existing output bridge.

* style(cli): format merged OpenTUI test callbacks

* test(core): control terminal conversion in PTY parking regression

* test(cli): preserve visible input across session rotation

* test(cli): cover shell recall provenance and streaming search selection

* test(cli): await cancellation before releasing held response

* fix(cli): preserve imported history ownership and active search

* docs(cli): synchronize consolidated OpenTUI validation status

* test(cli): await AppContainer configuration cleanup

* test(cli): mock Vim clipboard imports before module loading

* test(cli): check standalone asset roots for npm subpaths

* test(cli): share clipboard mocks across module export views

* test(cli): verify finalized MOD parts and shared JSON projection

* fix(cli): reuse Ink editing history for OpenTUI Unicode input

* test(cli): keep native Vim input callbacks void

* test(cli): preserve Escape boundaries in Unicode Vim fixtures

* test(cli): read the current composer across Vim remounts

* test(cli): align Unicode deletion cursor with Ink oracle

* fix(cli): preserve completion and escape precedence and validate status colors

* fix(cli): use boundary selection for the OpenTUI composer

* fix(cli): preserve attachment navigation with one completion

* test(cli): preserve Shift in the native redo key sequence

* fix(cli): display session titles in the OpenTUI composer border

* fix(cli): discard generated command work after cancellation

* test(cli): align Unicode unit cursor with Ink oracle

* test(cli): model native selection key delegation

* fix(cli): preserve Vim Escape ownership across listeners

* fix(cli): preserve shared Vim command messages in OpenTUI

* test(cli): observe rendered Vim mode during parity checks

* docs(cli): record integrated OpenTUI validation checkpoint

* fix(ci): account for dedicated TUI parity coverage

* fix(cli): preserve collapsed paste payloads across Vim undo

* docs(cli): refresh OpenTUI distribution acceptance status

---------

Co-authored-by: gary.gq <[email protected]>
This commit is contained in:
ChiGaoandgary.gq authored and GitHub committed 2026-10-23 10:27:53 +08:00
1 parent 8666ab091f
commit 32b4234b84
49 files changed
+2579 -308

No files matched your search

@@ -0,0 +1,249 @@
# POSIX npm OpenTUI distribution
[English](2026-10-21-opentui-npm-distribution.md) | [简体中文](2026-10-21-opentui-npm-distribution.zh-CN.md)
## Status and problem
The source checkpoint `0d353777bb054b9d5c9925f134dd63db7c316614` normally merges main `19d3289f4818617efdf31ca46d4db0522da36f95`, including the accepted input/output batch #15067 and Bun PTY #14046. It preserves all 49 distribution changes and the existing dependency inputs. Fresh macOS arm64 build and typecheck passed at this checkpoint. Affected consumer, native, bundle and installed-product gates are recorded separately in [#14959](https://github.com/QwenLM/qwen-code/pull/14959); a successful source build is not installed-artifact acceptance.
Earlier evidence remains bound to its source and artifact. Candidate `e8192c18` passed five installation layouts and 41 distinct actual-product cases, plus three separate synthetic protocol cases. Update fixtures used owned 0.0.0 clones and a candidate tarball, not two published releases. Later `7368b97` installation coverage recorded 20 functional passes and two characterized Ink deny gaps across 22 cases; `61f4e5be` source/runtime gates preceded this main integration. None proves the final combined artifact. Original failures, platform skips and corrected harness runs remain recorded separately; the final tarball must remeasure the 160 MiB unpacked-size gate.
The original strict interactive `--json-file` defect is covered by the accepted output changes, but the final installed combination still requires its own output regression. The historical serve shutdown failure requires current-candidate characterization. Native Linux cgroup/bwrap/landlock, other supported platforms and released-version upgrades remain separate open gates. Windows ConPTY diagnostics are not installed-Qwen acceptance and do not change the pinned Bun runtime. This remains an opt-in POSIX npm delivery; completion of all TUI migration and a default-renderer change require the overall migration gates.
The published npm manifest excludes `opentui-assets` and has no private Bun
runtime. On Node below 26.4, an explicit OpenTUI request falls back to Ink; on
newer Node, a bundled installation still lacks the native/parser assets needed
to boot OpenTUI. The separate Bun standalone preview already ships its runtime
and assets. npm/npx must gain the same explicit renderer choice without requiring
a global Bun installation or changing the default renderer.
Related work:
- [Runtime umbrella #11607](https://github.com/QwenLM/qwen-code/issues/11607).
- [Bun PTY #14046](https://github.com/QwenLM/qwen-code/pull/14046), merged at
reviewed `2972234e`; combined-artifact shell fidelity remains unverified.
- [R1 standalone updates #14934](https://github.com/QwenLM/qwen-code/pull/14934).
- [Publish executable bits #14907](https://github.com/QwenLM/qwen-code/pull/14907).
## Goals and boundaries
Support explicit OpenTUI sessions installed by npm or npx on darwin-arm64,
darwin-x64, linux-arm64, and linux-x64 (glibc). Keep Node as the npm launcher and
package-manager interpreter, and use the existing supervised CLI relaunch when
the selected interactive renderer needs private Bun.
Do not add `QWEN_TUI_RUNTIME`, a new package publishing family, a runtime cache,
startup downloads, a search for Bun on `PATH`, a compiled application, or changes
to the package manager. Do not switch the default renderer or runtime. Windows,
musl, whole-CLI sandbox images, and Node-only external hosts remain outside the
new handoff. Existing standalone runtime selection and updates remain intact.
The direct `process.execPath` audit also identifies a bounded remainder from
[#14134 at c807de17](https://github.com/QwenLM/qwen-code/pull/14134) and
[#14187 at d14b2ad1](https://github.com/QwenLM/qwen-code/pull/14187), consolidated
in this existing R3 delivery. The sandbox diagnostic, Mem0 MCP spawn and bundled
hook identity, cgroup launcher, and bwrap/landlock relay and protected-root pairs
reuse `getJsInterpreterCommand`. Keep the original payload, environment,
reaper and sandbox permissions; retain all four associated regression files.
The helper still falls back to the invoking interpreter for npm/source/SDK.
Three proposed swaps remain explicit exceptions: review Vitest retains its
host interpreter (and image `node` in the container); the PEM loader oracle and
TLS trust probe retain `process.execPath` to match the actual channel worker.
Do not change a probe's runtime independently of the worker it certifies.
The [runtime inventory](bun-runtime-boundary.md) records both the migrated
consumers and these exceptions. Application relaunches, default runtimes,
protocol hosts, and npm's original Node identity are unchanged.
## Mode and runtime selection
Reuse the existing explicit `QWEN_TUI_RENDERER=opentui` request. The handoff runs
after argument/settings resolution and before worktree creation, resume pickers,
authentication, MCP initialization, and renderer initialization. Share the
existing interactive predicate with configuration loading rather than parsing
raw arguments a second time. An explicit `-i` is interactive; otherwise a query
or prompt is non-interactive, and an empty prompt requires terminal stdin.
The private Bun handoff also requires terminal stdout, so redirected output
retains its invoking runtime.
| Invocation | Runtime behavior |
| -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Renderer unset, `ink`, or unknown | Existing runtime and renderer behavior |
| Explicit OpenTUI, interactive Node below 26.4, supported npm install | Private pinned Bun through the existing supervisor |
| Explicit OpenTUI, Node 26.4 or newer | Existing Node/OpenTUI path; existing FFI requirements still apply |
| Already running under Bun | Existing runtime; no recursive handoff |
| Screen reader, including settings and CLI overrides | Existing Ink capability fallback; strict request fails |
| Prompt/headless, pipe, help, version | Remain on the invoking runtime |
| `serve`, `mcp`, managed bootstrap, ACP, SDK, IDE protocol hosts | Remain on their existing runtime/protocol path |
| Dual-output protocol input/file or JSON descriptor | No new runtime handoff |
| Whole-CLI sandbox or already inside one | No host-private Bun handoff; preserve existing sandbox behavior |
| Windows, unsupported CPU, or Linux musl | No private Bun handoff |
| Missing optional runtime or non-executable binary | Node/Ink fallback; strict explicit request fails with a useful cause |
| Unsupported inherited Node runtime flags | Keep the original Node path; strict explicit request fails |
Only flags that are valid for this known Bun launch are passed to Bun. Node
memory tuning and Node loaders are not forwarded as Bun arguments. Preserve
script arguments, working directory, environment provenance, trusted launcher
identity, and the existing no-relaunch guard. An inherited request to disable
relaunch remains effective and cannot be silently overridden.
A Bun spawn failure is a startup error through the existing supervisor. Once
the Bun process has started, native renderer construction retains the existing
Ink fallback/strict-failure policy. Do not replay an already-started session
under a second runtime.
## Runtime and npm identities
Resolve the official platform package relative to the selected published CLI
installation, not the user's working directory. Require the expected published
manifest/bin layout, exact pinned runtime package version, and an executable
regular `bin/bun`. Development/source layouts and standalone manifests do not
opt into this npm handoff.
The original Node interpreter is captured at module boot before settings
or project environment loading, carried through the supervised handoff, and used
only for npm work. It must remain associated with this installation and cannot
be replaced by an env-file or `settings.env` value. The existing private
provenance exclusion also prevents a forged value from reaching nested children.
npm registry lookup, global config lookup,
managed update installation and smoke tests, and detached update workers use
that Node identity. They must not derive `npm-cli.js` from private Bun's path.
Keep `QWEN_CODE_CLI`, managed npm root/pin, and standalone trusted-launcher
validation unchanged. Exit 44 must reach the original Node wrapper so the
existing update/relaunch logic can select the new build. Keep the existing IPC
update-on-exit path, terminal process-group signals, stdin ownership, cleanup,
and supervisor-disconnect handling. Do not introduce another supervisor.
The Bun interpreter handoff on a TTY marks its child with the existing private
supervision key's `terminal` value. The child consumes that marker and requires
a real current or disconnected IPC channel before acting on it. If Node is
killed while the terminal remains open, IPC disconnection starts the existing
bounded cleanup immediately, followed by exit 129. Other terminal relaunches
keep their current policy; pipes and ACP retain the 120-second orphan grace.
While that terminal Bun child runs, its Node supervisor ignores SIGINT so the
child alone applies the existing confirmation window to a foreground-group
signal. The temporary listener is removed on close or failure. A SIGINT sent
only to the supervisor is ignored; terminal-group SIGINT and raw Ctrl+C input
are the interactive interrupt paths. TERM/HUP keep Node's default behavior and
are not forwarded a second time: IPC disconnection cleans up the child even
when the parent alone receives the signal. Acceptance checks parent-only
TERM/KILL with a live PTY keeper separately from group SIGINT and Ctrl+C bytes.
The OpenTUI renderer excludes SIGINT from its process exit listeners so the
shared interactive confirmation handler owns that signal. Its second confirmed
interrupt runs the existing registered renderer cleanup. Keep the other
OpenTUI 0.5.10 defaults: SIGTERM, SIGQUIT, SIGABRT, SIGHUP, SIGPIPE, SIGBREAK and
SIGBUS. Disabling the renderer's keyboard Ctrl+C handler alone does not prevent
its process-level SIGINT listener from destroying the terminal on the first
interrupt. Acceptance therefore requires usable input and an actual model
response after the first foreground-group signal, before testing double exit.
## Package construction
The generated npm manifest adds four exact optional dependencies from the
official `@oven` platform packages at Bun 1.3.14. Use the direct platform packages,
whose executable already exists in the tarball, rather than the `bun` wrapper
with its install script. This supports `--ignore-scripts`; `--omit=optional`
deliberately leaves the private Bun path unavailable.
Keep the complete relocated asset root in the published package. It contains
the parser worker, all existing grammar/SCM files, `web-tree-sitter` wasm, and
the four POSIX native libraries. OpenTUI 0.5.10 resolves every key through one
`OTUI_ASSET_ROOT`; splitting common assets from platform libraries without a
complete root would fail. Reuse the current copy logic and release staging
pattern, pinning native package versions from `pnpm-lock.yaml`.
Cross-platform asset staging is a packaging action with network access, not an
installation or startup action. Stage into an owned temporary directory, use
`--ignore-scripts`, copy only the declared native files, and remove temporary
content. The existing desktop caller uses the internal `--host-runtime`
preparation mode to skip universal npm staging. Its final copied runtime retains
only the selected desktop target's native library, including when `--skip-build`
reuses an npm-prepared dist. Common parser assets remain intact; source dist is
not pruned. This avoids regressing Linux desktop dependency scans and preserves
Windows target assets. Selection uses `desktopTarget()`, not the build machine's
`process.arch`, so cross-builds preserve their destination library. Ordinary
bundle construction remains unchanged.
VS Code extension preparation also uses `--host-runtime`, preserving its existing
optional dependencies without adding a private Bun to the embedded `qwen-cli`
layout. After copying, a platform VSIX keeps only its explicit `VSCODE_TARGET`
OpenTUI native library and the common parser assets, using the same target
validation as its existing ripgrep pruning. Universal builds, builds without an
explicit target, and unknown targets retain the existing asset-retention policy.
Only the extension's copied tree is pruned; root dist and existing destination
dependencies remain intact. Sandbox packaging produces an actual npm tarball
and continues to use normal npm preparation.
The existing 160 MiB unpacked npm-package limit remains a hard gate. Measure the
actual prepared tarball, including every published native library, rather than
extrapolating from a host-only bundle. Optional Bun dependencies add installation
size separately and must be reported. If the real artifact exceeds the limit,
this proposal is not deliverable until scope is revised; do not raise the limit
or omit required assets to obtain a passing check.
Preserve main's #14907 `npm publish` executable-bit protection. This branch does
not change release workflows to synchronize its older base. Verification uses
`npm pack` and the trusted main executable-bit checker, including vendored
ripgrep and the installed optional Bun executable. Do not reintroduce a
`pnpm pack` publication path.
## Dependencies and risks
Main now selects the #14046 `qwen-bun-pty` backend under Bun on POSIX and carries
its standalone PTY assets. This batch retains that implementation and adds the
92d4b193 reaped-leader guard; neither source integration nor earlier pipe-fallback
acceptance proves the new installed shell path has Ink/Node PTY fidelity. Its
real-process and artifact gates remain open. Windows Bun PTY remains unsupported.
Four platform archive contents can be checked on one host; native behavior cannot.
Record macOS arm64 execution separately from Linux/x64 or Windows checks.
Node 26.4 preserving Node/OpenTUI is an intentional compatibility choice here:
the later all-TUI-to-Bun milestone must still address these Node versions,
screen-reader behavior, sandboxing, Windows/musl, and the PTY dependency.
Runtime flags, managed npm updates, symlinked/npm-prefix installs, and optional
dependency omission are the highest-risk boundaries. Missing parser workers or
grammars can silently lose highlighting; a version-only smoke test cannot prove
either CLI loading or native renderer startup.
## Validation and acceptance
Use the global CLI to establish the mode/runtime baseline before product E2E.
The detailed plan and results live in `.qwen/e2e-tests/`. All tests use owned
temporary HOME, QWEN_HOME, npm prefix/cache, and shell configuration paths.
1. Unit tests cover the complete mode table, settings precedence, runtime flags,
package identity, version/executable checks, and original Node npm dispatch.
2. Packaging tests reject incomplete common assets, each missing native target,
mismatched versions, and an artifact over 160 MiB. Assert the published files
and exact optional dependency pins. Exercise the real IDE preparation,
metadata generation and copy chain with npm build/install processes replaced:
no Bun optional dependency reaches the VSIX installer, existing optional
dependencies and universal install flags remain, and target filtering
preserves common assets and the reusable source tree.
3. Build, typecheck, bundle, and focused tests pass for a frozen source commit.
4. Install the actual `npm pack` tarball with no global Bun; test a local npm
prefix and npx cache, normal install and `--ignore-scripts`. Strict OpenTUI
must load the native renderer and parser worker with real highlighted output.
5. Test `--omit=optional`, removed/non-executable runtime, missing grammar/native
files, read-only installation, and a working directory with a decoy Bun.
Verify fallback and strict failure separately.
6. Exercise actual npm registry/config subprocesses under original Node, managed
update staging and the 44 relaunch chain, IPC update-on-exit, SIGINT/SIGTERM,
and shutdown without leaked children. Synthetic fixtures must be labeled.
7. Verify headless/protocol/help/version remain on Node and keep output framing.
Node 26.4+, screen reader, standalone, Windows, and musl exclusions have
explicit regression coverage and honest platform execution limits.
8. Preserve npm publication executable bits and record tarball hash, unpacked
size, installed optional-runtime size, Node/Bun versions, and exact commit.
9. Validate the consolidated interpreter sites with a distinct private path,
paired Mem0 identity rejection, literal payload arguments and protected-root
overlap refusal. Real Linux cgroup membership/reaping and bwrap/landlock
execution remain separate gates; mock assertions cannot close them.
No result is marked passed before execution. Platform content checks do not
substitute for native platform tests, and this delivery does not close the
overall runtime-migration or Ink parity umbrella.
@@ -0,0 +1,200 @@
# POSIX npm OpenTUI 分发
[English](2026-10-21-opentui-npm-distribution.md) | [简体中文](2026-10-21-opentui-npm-distribution.zh-CN.md)
## 状态与问题
源码检查点 `0d353777bb054b9d5c9925f134dd63db7c316614` 通过普通 merge 纳入 main `19d3289f4818617efdf31ca46d4db0522da36f95`,包含已接受的输入/输出批次 #15067 和 Bun PTY #14046。49 项分发改动与现有依赖输入完整保留。该检查点的 macOS arm64 fresh build 和 typecheck 已通过。受影响 consumer、native、bundle 与安装版产品门禁分别记录在 [#14959](https://github.com/QwenLM/qwen-code/pull/14959);源码构建成功不能代替安装产物验收。
历史证据继续绑定原源码和产物。候选 `e8192c18` 通过五种安装布局与 41 个不同实际产品场景,另有三个合成协议场景通过。更新夹具使用自有 0.0.0 克隆和候选 tarball,不代表两个正式发布版本之间的升级。后续 `7368b97` 的 22 个安装用例记录为 20 个功能通过与两个 Ink deny 已知差异;`61f4e5be` 的源码/运行时门禁早于本次 main 整合,均不能证明最终组合产物。原始失败、平台跳过及修正后的 harness 运行分别保留;最终 tarball 须重新实测 160 MiB 解包体积门禁。
原 strict 交互 `--json-file` 缺陷已有已接受的输出改动覆盖,但最终安装组合仍须独立执行输出回归。历史 serve 关闭失败需要在当前候选上重新刻画。Linux 原生 cgroup/bwrap/landlock、其他支持平台及正式发布版本升级仍是独立未结门禁。Windows ConPTY 诊断不代表安装版 Qwen 通过,也不改变固定 Bun 版本。这仍是显式选择 OpenTUI 的 POSIX npm 交付;整体 TUI 迁移完成及默认 renderer 切换须满足整体迁移门禁。
当前 npm 发布清单排除了 `opentui-assets`,也没有私有 Bun 运行时。Node 低于
26.4 时,显式 OpenTUI 请求回退到 Ink;较新 Node 的 bundle 安装仍缺少启动
OpenTUI 所需的 native/parser 资产。独立的 Bun standalone preview 已包含运行时
与资产。npm/npx 应在不依赖全局 Bun、不改变默认 renderer 的前提下支持同一显式选择。
相关工作:
- [运行时总议题 #11607](https://github.com/QwenLM/qwen-code/issues/11607)。
- [Bun PTY #14046](https://github.com/QwenLM/qwen-code/pull/14046),已合入审阅版本
`2972234e`;新组合产物的 shell 一致性仍未验证。
- [R1 standalone 更新 #14934](https://github.com/QwenLM/qwen-code/pull/14934)。
- [发布执行权限 #14907](https://github.com/QwenLM/qwen-code/pull/14907)。
## 目标与边界
支持通过 npm 或 npx 安装后在 darwin-arm64、darwin-x64、linux-arm64、
linux-x64(glibc)上显式启用 OpenTUI。Node 继续承担 npm launcher 与包管理器
解释器角色;交互 renderer 需要私有 Bun 时复用既有受监督 CLI relaunch。
不增加 `QWEN_TUI_RUNTIME`、新的平台包发布体系、运行时缓存、启动下载、
`PATH` 全局 Bun 搜索、编译应用或包管理器变更。不切换默认 renderer/runtime。
Windows、musl、整个 CLI 的 sandbox 镜像和外部 Node 专用宿主不进入新 handoff。
既有 standalone 运行时选择与更新行为保留。
直接 `process.execPath` 审计还识别出
[#14134 的 c807de17](https://github.com/QwenLM/qwen-code/pull/14134) 与
[#14187 的 d14b2ad1](https://github.com/QwenLM/qwen-code/pull/14187) 中有界的剩余工作,
统一纳入现有 R3 交付。沙箱诊断、Mem0 MCP spawn 与 bundled hook 身份、cgroup
launcher,以及 bwrap/landlock relay/protected root 成对复用
`getJsInterpreterCommand`。保留原 payload、环境、reaper 与沙箱权限,并完整保留
四个相关回归文件。helper 在 npm/source/SDK 布局仍回退到调用者解释器。
三处原提议明确保留为例外:review Vitest 保留宿主解释器(容器中为镜像的 `node`);
PEM loader oracle 与 TLS 信任探针保留 `process.execPath`,匹配实际 channel worker。
不能单独改变认证某个 worker 的探针运行时。
[运行时清单](bun-runtime-boundary.zh-CN.md) 同时记录迁移消费者和这些例外。
应用重启、默认运行时、protocol host 与 npm 的原 Node 身份不变。
## 模式与运行时选择
复用既有显式 `QWEN_TUI_RENDERER=opentui` 请求。在参数/设置解析后、创建
worktree、resume picker、鉴权、MCP 初始化和 renderer 初始化前执行 handoff。
与配置加载共享既有交互判定,不再次解析原始参数。显式 `-i` 为交互;否则带
query/prompt 为非交互,空 prompt 要求 stdin 为终端。
私有 Bun handoff 还要求 stdout 为终端,因此重定向输出保持调用者运行时。
| 调用方式 | 运行时行为 |
| --------------------------------------------------------- | ------------------------------------------------- |
| renderer 未设置、`ink` 或未知值 | 保持既有运行时与 renderer 行为 |
| 显式 OpenTUI、交互 Node 低于 26.4、受支持 npm 安装 | 经既有 supervisor 启动私有固定版本 Bun |
| 显式 OpenTUI、Node 26.4 及以上 | 保留 Node/OpenTUI 路径;既有 FFI 要求仍适用 |
| 已运行于 Bun | 保持运行时,不递归 handoff |
| screen reader,包括设置与 CLI 覆盖 | 既有 Ink 能力回退;strict 请求失败 |
| prompt/headless、pipe、help、version | 保持调用者运行时 |
| `serve`、`mcp`、managed bootstrap、ACP、SDK、IDE 协议宿主 | 保持既有运行时/协议路径 |
| dual-output 协议输入/文件或 JSON descriptor | 不新增运行时 handoff |
| 整个 CLI 的 sandbox 或已处于其中 | 不使用宿主私有 Bun handoff,保持既有 sandbox 行为 |
| Windows、不支持的 CPU 或 Linux musl | 不进行私有 Bun handoff |
| 缺少 optional runtime 或二进制不可执行 | Node/Ink 回退;strict 显式请求失败并说明原因 |
| 不支持的继承 Node 运行时参数 | 保持原 Node 路径;strict 显式请求失败 |
只向 Bun 传递本次已知启动方式支持的参数,不将 Node 内存调优参数或 Node loader
作为 Bun 参数转发。保留脚本参数、工作目录、环境来源、可信 launcher 身份和既有
禁止 relaunch 的标记。不能静默覆盖继承的禁用 relaunch 请求。
Bun spawn 失败通过既有 supervisor 报启动错误。Bun 进程启动后,native renderer
构造仍采用既有 Ink 回退/strict 失败策略,不在第二个运行时重放已启动的会话。
## 运行时与 npm 身份
相对于实际选中的已发布 CLI 安装解析官方平台包,不从用户工作目录解析。要求符合
发布 manifest/bin 布局、运行时平台包版本精确匹配,以及 `bin/bun` 为可执行普通文件。
开发/source 布局和 standalone manifest 不进入 npm handoff。
原 Node 解释器在模块启动时、设置或项目环境加载前记录,经受监督 handoff
传递,并仅用于 npm 工作。该身份必须关联当前安装,不能被 env 文件或
`settings.env` 值替换。既有 private provenance 排除规则也阻止伪造值进入嵌套子进程。
npm registry 查询、全局配置查询、managed update 安装与 smoke test、后台更新
worker 均使用此 Node 身份,不能从私有 Bun 路径推导 `npm-cli.js`。
保留 `QWEN_CODE_CLI`、managed npm root/pin 和 standalone 可信 launcher 校验。
退出码 44 必须到达原 Node wrapper,使既有更新/relaunch 逻辑能够选择新构建。
保留 IPC update-on-exit、终端进程组信号、stdin 所有权、清理与 supervisor
断连处理,不引入第二套 supervisor。
TTY 上的 Bun 解释器 handoff 使用现有私有 supervision 键的 `terminal` 值标记
子进程。子进程消费该标记,且只有真实当前或已断连的 IPC 通道才会触发处理。
当 Node 被终止但终端仍打开时,IPC 断连立即开始既有的有界清理,随后以 129
退出。其他终端 relaunch 保留原策略;pipe 和 ACP 保留 120 秒孤儿宽限。
该终端 Bun 子进程运行期间,Node supervisor 忽略 SIGINT,由子进程独立对前台
进程组信号实施既有的确认窗口。临时 listener 在关闭或失败时移除。只发给
supervisor 的 SIGINT 被忽略;终端进程组 SIGINT 和 raw Ctrl+C 输入是交互中断
路径。TERM/HUP 保留 Node 默认行为,不重复转发;即使只有父进程收到信号,
IPC 断连也会清理子进程。验收将保持 PTY keeper 存活的父进程 TERM/KILL 与
进程组 SIGINT、Ctrl+C 字节分开检查。
OpenTUI renderer 的进程退出 listener 排除 SIGINT,由共享交互确认 handler
处理该信号。第二次确认中断调用既有注册的 renderer 清理。保留 OpenTUI 0.5.10
的其他默认信号:SIGTERM、SIGQUIT、SIGABRT、SIGHUP、SIGPIPE、SIGBREAK 和
SIGBUS。仅禁用 renderer 的键盘 Ctrl+C handler 无法阻止进程级 SIGINT listener
在首次中断时销毁终端。因此验收必须证明首次前台进程组信号后输入仍可用且实际收到
模型响应,再检查双击退出。
## 包构建
生成的 npm manifest 新增四个精确固定在 Bun 1.3.14 的官方 `@oven` 平台 optional
依赖。直接使用 tarball 已含可执行文件的平台包,不使用带安装脚本的 `bun`
wrapper。因此支持 `--ignore-scripts`;`--omit=optional` 会明确使私有 Bun 不可用。
已发布包包含完整重定位资产根:parser worker、全部既有 grammar/SCM 文件、
`web-tree-sitter` wasm 和四个平台 native library。OpenTUI 0.5.10 从同一个
`OTUI_ASSET_ROOT` 解析所有 key;将公共资产和平台库拆开放而没有完整根会失败。
复用当前复制逻辑与 release staging 方式,native 包版本从 `pnpm-lock.yaml` 固定。
跨平台资产 staging 是允许联网的打包动作,不是安装或启动动作。在独占临时目录使用
`--ignore-scripts`,只复制声明的 native 文件,并清理临时内容。既有 desktop 调用者
使用内部 `--host-runtime` 准备模式,跳过通用 npm staging。最终复制出的 runtime
只保留所选 desktop 目标的 native library,也覆盖 `--skip-build` 复用已准备 npm
dist 的情况。公共 parser 资产保持完整,不裁剪原 dist。这样避免 Linux desktop
依赖扫描回归,并保留 Windows 目标资产。普通 bundle 构建保持原行为。
选择依据为 `desktopTarget()`,而不是构建机的 `process.arch`,因此交叉构建保留
目标平台库。
VS Code 扩展准备也使用 `--host-runtime`,保留既有 optional 依赖,不向嵌入的
`qwen-cli` 布局添加私有 Bun。复制后,平台 VSIX 仅保留显式 `VSCODE_TARGET`
对应的 OpenTUI native 库和公共 parser 资产,沿用既有 ripgrep 裁剪的目标校验。
universal、未显式指定目标和未知目标继续沿用原来的资产保留策略。
仅裁剪扩展副本,根 dist 与目标目录中既有依赖不受影响。sandbox 打包产生真实 npm
tarball,因此继续使用普通 npm 准备模式。
既有 npm 包解包大小 160 MiB 是硬门槛。必须测量真实准备后的 tarball,包含全部已发布
native library,不能从宿主专用 bundle 外推。optional Bun 依赖带来的安装大小另行
报告。真实产物超过门槛时,本方案尚不可交付,需调整范围;不能提高上限或删除必要资产
来获得通过。
保留 main 中 #14907 的 `npm publish` 执行权限保护。本分支不为同步旧底座修改
release workflow。验证使用 `npm pack` 和可信 main 的执行权限检查器,包含 vendored
ripgrep 与已安装 optional Bun executable,不重新引入 `pnpm pack` 发布路径。
## 依赖与风险
main 现已在 POSIX Bun 下选择 #14046 的 `qwen-bun-pty` 后端,并携带其 standalone
PTY 资产。本批保留该实现并加入 92d4b193 已回收主进程 guard;源码整合及此前的 pipe
回退验收都不能证明新安装产物的 shell 路径与 Ink/Node PTY 一致。真实进程与产物门禁
仍待执行。Windows Bun PTY 仍不支持。
单机可以检查四个平台归档内容,不能证明四平台 native 行为。macOS arm64 执行证据
必须与 Linux/x64 或 Windows 检查分开记录。保留 Node 26.4 的 Node/OpenTUI 是
本批有意采用的兼容选择;后续全部 TUI 迁移 Bun 的里程碑仍需覆盖这些 Node 版本、
screen reader、sandbox、Windows/musl 与 PTY 依赖。
运行时参数、managed npm 更新、符号链接/npm-prefix 安装与 optional 依赖省略
是最高风险边界。缺少 parser worker/grammar 可能静默丢失高亮;仅检查版本不能
证明 CLI 加载或 native renderer 启动。
## 验证与验收
先用全局 CLI 建立模式/运行时基线,再做产品 E2E。详细计划和结果保存在
`.qwen/e2e-tests/`。所有测试使用独占临时 HOME、QWEN_HOME、npm prefix/cache
和 shell 配置路径。
1. 单测覆盖完整模式表、设置优先级、运行时参数、包身份、版本/执行权限校验,以及原
Node 的 npm 调度。
2. 打包测试拒绝公共资产不完整、任一目标 native 缺失、版本不匹配和超过 160 MiB
的产物;断言发布文件与精确 optional 依赖版本。执行真实 IDE 准备、metadata
生成与复制链路,仅替换 npm 构建/安装进程:确保 VSIX 安装器不会收到 Bun
optional 依赖,既有 optional 依赖与 universal 安装参数保留,目标裁剪保留
公共资产和可复用源目录。
3. 对冻结源码提交执行 build、typecheck、bundle 与 focused tests。
4. 无全局 Bun 时安装真实 `npm pack` tarball,验证本地 npm prefix 与 npx cache、
普通安装及 `--ignore-scripts`。strict OpenTUI 必须加载 native renderer 和
parser worker,产生实际高亮输出。
5. 覆盖 `--omit=optional`、运行时缺失/不可执行、grammar/native 缺失、只读安装和
当前目录存在伪造 Bun,分别验证 fallback 与 strict 失败。
6. 在原 Node 下执行真实 npm registry/config 子进程、managed update staging、
退出码 44 重启链、IPC update-on-exit、SIGINT/SIGTERM 和无残留进程的退出。
synthetic fixture 必须明确标注。
7. 验证 headless/协议/help/version 保持 Node 与原输出 framing;Node 26.4+、
screen reader、standalone、Windows 与 musl 排除项有明确回归覆盖和平台执行限制。
8. 保留 npm 发布执行权限,记录 tarball hash、解包大小、optional runtime 安装大小、
Node/Bun 版本与精确 commit。
9. 使用不同的私有解释器路径验证整合调用点,覆盖成对的 Mem0 身份拒绝、literal
payload 参数与 protected root 重叠拒绝。真实 Linux cgroup membership/reaping
和 bwrap/landlock 执行仍是独立门禁,mock 断言不能关闭它们。
执行前不标记 PASS。平台内容检查不替代对应平台 native 测试,本交付不关闭总体
运行时迁移或 Ink 一致性总议题。
+11 -2
View File
@@ -168,8 +168,10 @@ leader, its process group, its descendants, and every process in its session
while the leader is still unreaped. That list covers the foreground jobs an
interactive shell runs in their own process groups. SIGTERM goes first; after
a 500 ms grace, SIGKILL goes to a fresh listing, which keeps earlier members
only while they are still the same process (same start time), so a recycled
pid is never signalled. The backend then closes the master fd and ends every
only while they are still the same process (same start time). Once the native
exit callback marks the leader reaped, signals skip its numeric process-group
ID and leader PID; identity-checked descendants still receive the cleanup
signals. The backend then closes the master fd and ends every
drain path (retry timer, pending exit, hold, write queue). After the failure, `write` and `resize` are no-ops (matching the
registry guards above them), held bytes still in flight from an in-flight
read are neither delivered nor re-charged, and a `resume()` replay stops at
@@ -200,6 +202,13 @@ was located. The bounded hold reads at full speed and stays byte-exact
below the cap, and parking at the cap bounds memory without dropping a
byte.
The per-pty parking byte test explicitly disables ONLCR in its child before
the flood. It compares the complete LF stream and an explicit CRLF sentinel
without normalizing received bytes. Default-mode macOS controls produced extra
CR through both wrappers; their failures remain recorded. This isolates the
adapter's byte-preservation contract from terminal output conversion, without
claiming that the exact source of those extra CR has been established.
### Consumer wiring of the failure
- **ShellExecutionService (foreground PTY path)**: a `QWEN_PTY_WRITE_LIMIT`
+7 -1
View File
@@ -134,7 +134,8 @@ exit 照常上报)。
(Linux `sid`)或控制终端(macOS `tdev`)上的所有进程——在主进程尚未被回收
时由 `ps` 列出。这份列表包括交互式 shell 放在独立进程组里的前台作业。先发
SIGTERM;500 ms 宽限后对重新列出的进程发 SIGKILL,早先的成员只有在仍是同一
进程(启动时间相同)时才保留,因此不会向被复用的 pid 发信号。随后关闭
进程(启动时间相同)时才保留。原生 exit 回调标记主进程已回收后,不再向其数值
进程组 ID 或主进程 PID 发信号;身份核验仍匹配的后代继续接收清理信号。随后关闭
master fd,终结所有排空路径(重试定时器、挂起的 exit、保持区、写队列)。失败之后 `write` 与 `resize` 均为 no-op(与其上层的
registry 守卫一致),在途读取迟到的字节既不投递也不重新计入(已释放
的)保持预算,`resume()` 重放会在第一个终止本 pty 的数据监听器处停止。
@@ -158,6 +159,11 @@ termios 输出处理(ONLCR)计入后,连续排空与滴读两条臂在 Nod
产出逐字节一致,并未定位到任何破坏机制。有界保持在上限之内全速读取且
逐字节精确,到达上限后停读,在不丢任何字节的前提下给内存设界。
单 pty 停读字节测试在洪流之前显式关闭子进程的 ONLCR,严格比较完整 LF
字节流及显式 CRLF 哨兵,不归一化收到的字节。macOS 默认模式对照在两种
包装下均产生过额外 CR,原失败记录保留。这将适配器的字节保真契约与终端
输出转换分开验证,不声称已经确定额外 CR 的精确产生层。
### 失败在消费者侧的接线
- **ShellExecutionService(前台 PTY 路径)**:`QWEN_PTY_WRITE_LIMIT`
+42 -32
View File
@@ -48,26 +48,39 @@ or compatibility.
### 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 backend selection (under Bun on POSIX, PTY loads the `qwen-bun-pty` backend — see [bun-pty-backend.md](bun-pty-backend.md); Windows stays gated) | detection via `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts` (`bundledMem0Server`) | bundled mem0 MCP child; identity check `server.command !== process.execPath` | not yet; must migrate with its identity check together (S3) |
| `packages/core/src/agents/arena/ArenaManager.ts` | (also) worker-side in-process runtime | launcher-shaped, see above |
| `packages/cli/src/serve/pem-certificate-blocks.ts` | helper child spawn | not yet (S3 sweep) |
| `packages/cli/src/commands/review/test-efficacy.ts` | spawns vitest via `process.execPath` (dev-tooling path) | intentionally not; host-tool identity |
| Site | Use | Wired to `getJsInterpreterCommand` in this change |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `packages/core/src/code-mode/host-client.ts` (`hostCommand`) | code-mode host child (`host.js` / bundled `codeModeHost.js`; dev: `--import tsx host.ts`) | yes |
| `packages/core/src/sandbox/file-worker-client.ts` | sandbox file-worker child (`sandboxAsset('file-worker')`) | yes |
| `packages/core/src/hooks/hookRunner.ts` (surviving-hook supervisor) | `spawn(--input-type=commonjs --eval SUPERVISOR …)`, detached | yes |
| `packages/core/src/hooks/hook-command-cgroup.ts` (`launch`) | cgroup launcher for hooks and managed child runs; payload and reaper unchanged | yes (R3); native Linux validation pending |
| `packages/core/src/sandbox/bwrap-execution.ts` (`executeBwrap`) | JS relay and protected root use the resolved interpreter | yes (R3); native Linux validation pending |
| `packages/core/src/sandbox/landlock-execution.ts` (`executeLandlock`) | JS relay and protected root use the resolved interpreter | yes (R3); native Linux validation pending |
| `packages/core/src/utils/getPty.ts` (`loadPty`) | Bun detection for the PTY backend selection (under Bun on POSIX, PTY loads the `qwen-bun-pty` backend — see [bun-pty-backend.md](bun-pty-backend.md); Windows stays gated) | detection via `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts` (`bundledMem0Server`) | bundled Mem0 MCP child and bundled-hook identity use the same interpreter | yes, together (R3); artifact validation pending |
| `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` | certificate-loader oracle for channel workers | intentionally not; same process.execPath as the worker |
| `packages/cli/src/commands/sandbox.ts` | sandbox diagnostic JS uses the resolved interpreter | yes (R3); native Linux validation pending |
| `packages/cli/src/serve/run-qwen-serve.ts` | TLS trust probe for channel workers | intentionally not; same process.execPath as the worker |
| `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.
R3 consolidates the remaining interpreter work from
[#14134 at c807de17](https://github.com/QwenLM/qwen-code/pull/14134)
and its regression coverage from
[#14187 at d14b2ad1](https://github.com/QwenLM/qwen-code/pull/14187).
It migrates the sandbox diagnostic, Mem0 spawn/identity, cgroup launcher, and
bwrap/landlock relay/protected-root pairs through the existing helper. cgroup
retains both hook and managed-child-run consumers and `reapOnParentExit`.
Source checkpoint `0d353777` incorporates accepted main `19d3289f`, including #14046 and #15067, while preserving this interpreter boundary and dependency inputs. Fresh build and typecheck passed on macOS arm64. Historical e819/b734/7368/61f results retain their original source, runtime and artifact scope rather than proving final-combination acceptance. The [distribution design](2026-10-21-opentui-npm-distribution.md) separates those results, current-candidate gates and installed-artifact proof. Native Linux cgroup/bwrap/landlock and other platform/released-update acceptance remain open.
Three proposed swaps are deliberately not taken from #14134. Review Vitest
keeps its invoking host interpreter; its container branch still uses the
image's `node`. The PEM oracle and TLS trust probe keep `process.execPath`,
matching the actual channel-worker launcher. Selecting a manifest interpreter
only for those probes could judge a different CA/TLS implementation than the
worker uses. This changes neither the daemon runtime nor npm's Node identity.
The [R3 distribution design](2026-10-21-opentui-npm-distribution.md) records the
bounded delivery and acceptance plan; remaining S3/S4 entries stay pending.
### Update / install identity (must stay host-Node)
@@ -81,7 +94,8 @@ platform.
### Complete literal call-site audit at the fixed parent
The tables above are a historical conceptual grouping, not an exhaustive list.
The conceptual tables above track the current boundaries. The table below is a
historical snapshot, not an exhaustive list of current helper consumers.
This table accounts for every executable literal `process.execPath` read in
`packages/**/src/**` at parent
`8b4a0a25ea2bc570a1a6070e05182f29501a1b7f`: 34 files, 48 occurrences.
@@ -186,16 +200,15 @@ source installs do not have.
`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,
- `runtime` is metadata with no current production read site: 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.
- The sandbox file-worker, bwrap/landlock relay launch, and protected roots now
use the same resolved interpreter. The R3 consolidation retains tests with a
different private path; native Linux execution remains unverified.
- 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
@@ -203,9 +216,9 @@ source installs do not have.
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.
Migration rule: arbitrary-JS workers use `getJsInterpreterCommand`; the
explicit host-tool and worker-runtime probes above preserve their host identity.
Sites that re-invoke the app use `QWEN_CODE_CLI` / the entry fallback.
## Cross-runtime test coverage
@@ -247,9 +260,6 @@ their own real process and artifact evidence either way.
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.
## Standalone update integration (R1)
+33 -19
View File
@@ -35,21 +35,35 @@
### 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 检测(POSIX 平台 Bun 下 PTY 加载 `qwen-bun-pty` 后端 —— 见 [bun-pty-backend.zh-CN.md](bun-pty-backend.zh-CN.md);Windows 仍门控) | 检测走 `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts`(`bundledMem0Server`) | 内置 mem0 MCP 子进程;身份判断 `server.command !== process.execPath` | 暂未;须与其身份判断一起迁移(S3) |
| `packages/core/src/agents/arena/ArenaManager.ts` | (另)worker 侧 in-process 运行时 | launcher 形态,见上表 |
| `packages/cli/src/serve/pem-certificate-blocks.ts` | 辅助子进程 spawn | 暂未(S3 清扫) |
| `packages/cli/src/commands/review/test-efficacy.ts` | 经 `process.execPath` 启动 vitest(开发工具路径) | 有意不接入;保持宿主工具身份 |
| 位置 | 用途 | 本变更是否接入 `getJsInterpreterCommand` |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `packages/core/src/code-mode/host-client.ts`(`hostCommand`) | code-mode host 子进程(`host.js` / 内置 `codeModeHost.js`;dev 为 `--import tsx host.ts`) | 是 |
| `packages/core/src/sandbox/file-worker-client.ts` | sandbox file-worker 子进程(`sandboxAsset('file-worker')`) | 是 |
| `packages/core/src/hooks/hookRunner.ts`(幸存 hook 监督进程) | `spawn(--input-type=commonjs --eval SUPERVISOR …)`,detached | 是 |
| `packages/core/src/hooks/hook-command-cgroup.ts`(`launch`) | hook 与 managed child run 的 cgroup launcher;payload 与 reaper 不变 | 是(R3);Linux 原生验证待执行 |
| `packages/core/src/sandbox/bwrap-execution.ts`(`executeBwrap`) | JS relay 与 protected root 使用同一解析出的解释器 | 是(R3);Linux 原生验证待执行 |
| `packages/core/src/sandbox/landlock-execution.ts`(`executeLandlock`) | JS relay 与 protected root 使用同一解析出的解释器 | 是(R3);Linux 原生验证待执行 |
| `packages/core/src/utils/getPty.ts`(`loadPty`) | PTY 后端选择的 Bun 检测(POSIX 平台 Bun 下 PTY 加载 `qwen-bun-pty` 后端 —— 见 [bun-pty-backend.zh-CN.md](bun-pty-backend.zh-CN.md);Windows 仍门控) | 检测走 `isBunRuntime()` |
| `packages/cli/src/config/mem0-settings.ts`(`bundledMem0Server`) | 内置 Mem0 MCP 子进程与 bundled hook 身份使用同一解释器 | 是,同步迁移(R3);产物验证待执行 |
| `packages/core/src/agents/arena/ArenaManager.ts` | (另)worker 侧 in-process 运行时 | launcher 形态,见上表 |
| `packages/cli/src/serve/pem-certificate-blocks.ts` | channel worker 的证书加载 oracle | 有意不接入;与 worker 保持同一 process.execPath |
| `packages/cli/src/commands/sandbox.ts` | 沙箱诊断 JS 使用解析出的解释器 | 是(R3);Linux 原生验证待执行 |
| `packages/cli/src/serve/run-qwen-serve.ts` | channel worker 的 TLS 信任探针 | 有意不接入;与 worker 保持同一 process.execPath |
| `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 两行适用于所有平台。
R3 整合 [#14134 的 c807de17](https://github.com/QwenLM/qwen-code/pull/14134)
中剩余解释器工作,以及
[#14187 的 d14b2ad1](https://github.com/QwenLM/qwen-code/pull/14187) 中对应回归覆盖。
沙箱诊断、Mem0 spawn/身份、cgroup launcher、bwrap/landlock relay/protected root
成对接入现有 helper。cgroup 保留 hook 与 managed-child-run 两类消费者及
`reapOnParentExit`。源码检查点 `0d353777` 纳入已接受的 main `19d3289f`,包含 #14046 与 #15067,同时保留本解释器边界与依赖输入。macOS arm64 fresh build 和 typecheck 已通过。历史 e819/b734/7368/61f 结果保留各自原始源码、运行时与产物范围,不能代替最终组合验收。[分发设计](2026-10-21-opentui-npm-distribution.zh-CN.md) 分别记录这些结果、当前候选门禁与安装产物证明。Linux 原生 cgroup/bwrap/landlock、其他平台及正式发布版本升级验收仍未完成。
明确不接收 #14134 提议的三处替换。review Vitest 保留调用它的宿主解释器,容器分支
继续使用镜像的 `node`。PEM oracle 和 TLS 信任探针保留 `process.execPath`,与
实际 channel-worker launcher 一致。只为探针选择 manifest 解释器可能判断与
worker 不同的 CA/TLS 实现。本次不改变 daemon 运行时或 npm 的 Node 身份。
[R3 分发设计](2026-10-21-opentui-npm-distribution.zh-CN.md) 记录本批范围与验收计划;
其余 S3/S4 项仍待完成。
### 更新 / 安装身份(必须保持宿主 Node)
@@ -63,7 +77,8 @@
### 固定父提交的完整字面调用点复核
上表是按概念分组的历史清单,不是穷尽列表。本表补齐固定父提交
上方概念表记录当前边界;下表保留历史快照,不是当前 helper 消费者的穷尽列表。
下表补齐固定父提交
`8b4a0a25ea2bc570a1a6070e05182f29501a1b7f` 上 `packages/**/src/**`
的所有可执行 `process.execPath` 字面读取:34 文件、48 处。行号固定于该父提交;
同文件不同用途逐一列出。扫描排除注释、`*.test.*`、`*.spec.*`、`__tests__`、
@@ -134,12 +149,12 @@ S1 已接入选择器的三处是 `core/src/code-mode/host-client.ts`、`core/sr
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 对该字段的任何用途)尚未在此实现。
- `runtime` 是当前没有生产读点的元数据:已迁移调用方仅消费 `command`。按运行时区分的消费逻辑(包括未来 S3 对该字段的任何用途)尚未在此实现。
- 结果按进程记忆:首次调用方的 URL 决定解析结果,后续调用方 URL 被忽略;当前生产调用方共享一个安装根。`resetJsInterpreterCacheForTests()` 清除缓存。
- 沙箱 file-worker 选择此解释器,但 bwrap/landlock 的 allowlist 仍暂存 `realpathSync(process.execPath)`。这项衔接仍是尚未实现的 S3 迁移;本 PR 不证明解析出的解释器与暂存二进制不同时的布局已受支持。
- 沙箱 file-worker、bwrap/landlock relay 启动和 protected root 现在使用同一解析出的解释器。R3 整合保留使用不同私有路径的测试;Linux 原生执行仍未验证。
- 支持范围:**基于解释器的 Node/Bun 分发**。拒绝声明时返回 `process.execPath`,它在编译形态下会是应用二进制。本 PR 不支持编译应用,也没有提供编译制品生产路径。未来该形态必须显式打包解释器并独立验证失败行为后才能采用;当前回退不保证在编译形态下得到可用解释器。
迁移规则:任何新增的、以子进程方式运行 JS 文件或 `-e` 片段的代码必须走 `getJsInterpreterCommand`;再唤起应用的位置走 `QWEN_CODE_CLI` / 入口回退。
迁移规则:任意 JS worker 使用 `getJsInterpreterCommand`;上文明确列出的宿主工具和 worker-runtime 探针保留宿主身份。再唤起应用的位置使用 `QWEN_CODE_CLI` / 入口回退。
## 跨运行时测试覆盖
@@ -167,7 +182,6 @@ Windows 跳过可捕获 SIGTERM 用例;缺少 Bun 二进制时跳过 Bun 腿
## 遗留问题
- 未来编译形态下 `ArenaManager` / managed-worker 重启:编译包必须发布 launcher(`QWEN_CODE_CLI`),上列同解释器回退位置需要 launcher 形态的迁移。已列入 S4 跟踪。
- `mem0-settings.ts` 的身份判断与 `command === process.execPath` 耦合;S3 中与其 spawn 位置一起迁移,避免身份与 launcher 漂移。
## Standalone 更新集成(R1)
+4 -1
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getJsInterpreterCommand } from '@qwen-code/qwen-code-core/utils/runtime-info.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -225,7 +226,9 @@ export const sandboxCommand: CommandModule = {
const handle = await executeSandbox(
policy,
{
executable: fs.realpathSync(process.execPath),
executable: fs.realpathSync(
getJsInterpreterCommand(import.meta.url).command,
),
args: ['-e', program, inside, outside],
cwd: policy.workspace,
env: { PATH: '/usr/bin:/bin' },
+3 -26
View File
@@ -5,6 +5,7 @@
*/
import { readSshWorkspace } from '../serve/ssh-workspace-store.js';
import { isInteractiveInvocation } from '../utils/interactive-mode.js';
import { SshExecutionEnvironment } from '@qwen-code/qwen-code-core/services/ssh-execution-environment.js';
import {
isLoopProtectionMode,
@@ -1987,31 +1988,7 @@ export async function loadCliConfig(
throw err;
}
// Interactive mode determination with priority:
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
// 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive
// 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive
const hasQuery = !!argv.query;
const hasPrompt = !!argv.prompt;
let interactive: boolean;
if (argv.promptInteractive) {
// Priority 1: Explicit -i flag means interactive
interactive = true;
} else if (
(outputFormat === OutputFormat.STREAM_JSON ||
outputFormat === OutputFormat.JSON) &&
(hasQuery || hasPrompt)
) {
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
interactive = false;
} else if (!hasQuery && !hasPrompt) {
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
interactive = process.stdin.isTTY ?? false;
} else {
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
// (fallback for edge cases where query/prompt is provided with TEXT output)
interactive = false;
}
const interactive = isInteractiveInvocation(argv);
// ── Unified permissions construction ─────────────────────────────────────
// All permission sources are merged here, before constructing Config.
// The resulting three arrays are the single source of truth that Config /
@@ -2545,7 +2522,7 @@ export async function loadCliConfig(
// pipe default while interactive and protocol-driven modes keep PTY.
const isExplicitOneShotPrompt =
!interactive &&
hasPrompt &&
Boolean(argv.prompt) &&
!isAcpMode &&
!(argv.inputFile ?? settings.dualOutput?.inputFile) &&
inputFormat !== InputFormat.STREAM_JSON;
+50 -1
View File
@@ -21,7 +21,10 @@ import {
ENV_ACP_REPEATED_TOOL_FAILURE_GUARD,
PRIVATE_RELAUNCH_ENV_PROVENANCE,
} from './shared-env-keys.js';
import { RELAUNCH_SUPERVISED_ENV } from '../utils/env-provenance.js';
import {
NPM_RUNTIME_CONTEXT,
RELAUNCH_SUPERVISED_ENV,
} from '../utils/env-provenance.js';
import type { Settings } from './settingsSchema.js';
import { TrustLevel, resetTrustedFoldersForTesting } from './trustedFolders.js';
import {
@@ -102,6 +105,8 @@ const TRACKED_ENV = [
PRIVATE_RELAUNCH_ENV_PROVENANCE,
PRIVATE_RELAUNCH_ENV_PROVENANCE.toLowerCase(),
RELAUNCH_SUPERVISED_ENV,
NPM_RUNTIME_CONTEXT,
NPM_RUNTIME_CONTEXT.toLowerCase(),
ENV_ACP_REPEATED_TOOL_FAILURE_GUARD,
'QWEN_CODE_PENDING_COMPILE_CACHE',
'QWEN_CODE_TRUSTED_FOLDERS_PATH',
@@ -1460,6 +1465,50 @@ describe('loadEnvironment', () => {
expect(process.env['RUNTIME_DOTENV']).toBe('allowed');
});
it.each(['project .env', 'home .env', 'settings.env'])(
'preserves launcher npm identity against %s, including reload and child snapshots',
(source) => {
const workspace = makeWorkspace();
const original = JSON.stringify({
node: '/trusted/node',
bun: '/private/bun',
cli: '/installed/cli.js',
});
const forged = JSON.stringify({
node: '/workspace/attacker',
bun: '/private/bun',
cli: '/installed/cli.js',
});
process.env[NPM_RUNTIME_CONTEXT] = original;
const keys = [NPM_RUNTIME_CONTEXT, NPM_RUNTIME_CONTEXT.toLowerCase()];
const settings = testSettings({ advanced: { excludedEnvVars: [] } });
if (source === 'settings.env') {
settings.env = Object.fromEntries(keys.map((key) => [key, forged]));
} else {
fs.writeFileSync(
path.join(source === 'home .env' ? os.homedir() : workspace, '.env'),
keys.map((key) => `${key}=${forged}`).join('\n'),
);
}
loadEnvironment(settings, workspace);
reloadEnvironment(settings, workspace);
expect(process.env[NPM_RUNTIME_CONTEXT]).toBe(original);
expect(process.env[NPM_RUNTIME_CONTEXT.toLowerCase()]).toBeUndefined();
const child = buildRuntimeEnvironment(
settings,
workspace,
process.env,
).effectiveEnv;
expect(child[NPM_RUNTIME_CONTEXT]).toBe(original);
expect(child[NPM_RUNTIME_CONTEXT.toLowerCase()]).toBeUndefined();
expect(
buildRuntimeEnvironment(settings, workspace, {}).effectiveEnv[
NPM_RUNTIME_CONTEXT
],
).toBeUndefined();
},
);
// The private Conversations provenance marker is a fixed constant rather
// than a per-spawn nonce, so the home-scoped exemption from
// PROJECT_ENV_HARDCODED_EXCLUSIONS must not apply to it: a home `.env`
+48 -1
View File
@@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createBundledMem0Server, bundledMem0Hooks } from './mem0-settings.js';
import * as runtimeInfo from '@qwen-code/qwen-code-core/utils/runtime-info.js';
import { resolveHookSettingsForConfig } from './hook-settings.js';
vi.mock('node:fs', async (importOriginal) => {
@@ -24,7 +25,10 @@ vi.mock('@qwen-code/qwen-code-core/utils/bundlePaths.js', () => ({
resolveBundleDir: () => process.cwd(),
}));
afterEach(() => vi.unstubAllEnvs());
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe('bundled Mem0 settings', () => {
it('generates a read-only scoped MCP without serializing credentials', () => {
@@ -196,6 +200,49 @@ describe('bundled Mem0 settings', () => {
});
});
it('uses the private interpreter for both Mem0 spawn and bundled identity', () => {
const interpreter = join(
process.cwd(),
'private runtime',
'bun',
'bin',
'bun',
);
vi.spyOn(runtimeInfo, 'getJsInterpreterCommand').mockReturnValue({
command: interpreter,
runtime: 'bun',
});
const server = createBundledMem0Server(
{ baseUrl: 'https://mem0.example', enableWrites: true },
process.cwd(),
true,
);
expect(server.command).toBe(interpreter);
const hooks = bundledMem0Hooks(server);
const quote = (value: string) =>
"'" +
value.replaceAll("'", process.platform === 'win32' ? "''" : "'\\''") +
"'";
expect(hooks).toEqual({
PreToolUse: [
expect.objectContaining({
hooks: [
expect.objectContaining({
command:
(process.platform === 'win32' ? '& ' : 'exec ') +
quote(interpreter) +
' ' +
quote(join(process.cwd(), 'mem0', 'write-confirmation.js')),
}),
],
}),
],
});
for (const command of [process.execPath, 'node', 'bun']) {
expect(bundledMem0Hooks({ ...server, command })).toBeUndefined();
}
});
it('pins the spawn interpreter and the timeout slack to the runtime contract', () => {
const server = createBundledMem0Server(
{ baseUrl: 'https://mem0.example' },
+3 -2
View File
@@ -10,6 +10,7 @@ import { dirname, join } from 'node:path';
import { z } from 'zod';
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
import { resolveBundleDir } from '@qwen-code/qwen-code-core/utils/bundlePaths.js';
import { getJsInterpreterCommand } from '@qwen-code/qwen-code-core/utils/runtime-info.js';
import { findGitRoot } from '@qwen-code/qwen-code-core/utils/gitUtils.js';
import { getProjectHash } from '@qwen-code/qwen-code-core/utils/paths.js';
import { isInternalSecretEnvVar } from '@qwen-code/qwen-code-core/utils/sanitize-child-env.js';
@@ -150,7 +151,7 @@ export function createBundledMem0Server(
...(enableWrites ? { write: { enabled: true } } : {}),
};
return {
command: process.execPath,
command: getJsInterpreterCommand(import.meta.url).command,
args: [mem0RuntimePath()],
env: { QWEN_BUNDLED_MEM0_CONFIG: JSON.stringify(config) },
includeTools: [
@@ -205,7 +206,7 @@ export function bundledMem0Hooks(
!server.includeTools?.includes('context_remember') ||
server.scope !== undefined ||
server.extensionName !== undefined ||
server.command !== process.execPath ||
server.command !== getJsInterpreterCommand(import.meta.url).command ||
server.args?.length !== 1 ||
server.args[0] !== mem0RuntimePath()
)
@@ -13,6 +13,7 @@ import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
import {
PRIVATE_RELAUNCH_ENV_PROVENANCE,
RELAUNCH_SUPERVISED_ENV,
NPM_RUNTIME_CONTEXT,
} from '../utils/env-provenance.js';
export { PRIVATE_RELAUNCH_ENV_PROVENANCE };
@@ -294,6 +295,7 @@ export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [
// Only a relaunch supervisor marks its child; a forged marker would make a
// piped, one-shot run exit as if its supervisor had gone.
RELAUNCH_SUPERVISED_ENV,
NPM_RUNTIME_CONTEXT,
];
// Windows env lookup is case-insensitive, so exact-case membership would let
@@ -339,6 +341,7 @@ const PRIVATE_PROVENANCE_ENV_KEYS: ReadonlySet<string> = new Set([
PRIVATE_CONVERSATIONS_RUNTIME_ENV.toLowerCase(),
PRIVATE_RELAUNCH_ENV_PROVENANCE.toLowerCase(),
RELAUNCH_SUPERVISED_ENV.toLowerCase(),
NPM_RUNTIME_CONTEXT.toLowerCase(),
'sandbox',
'sandbox_enforcement',
]);
+24 -1
View File
@@ -124,6 +124,8 @@ import {
superviseInProcess,
} from './utils/processUtils.js';
import { getInstallationInfo } from './utils/installationInfo.js';
import { isInteractiveInvocation } from './utils/interactive-mode.js';
import { selectNpmBunHandoff } from './utils/npm-runtime.js';
import { createLocalWorkflowAncestorTrustProvider } from './config/trustedFolders.js';
const debugLogger = createDebugLogger('STARTUP');
@@ -686,6 +688,21 @@ export async function main() {
return shouldRelaunch ? UPDATE_COMPLETE_EXIT_CODE : 0;
};
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
const npmBunHandoff = selectNpmBunHandoff({
interactive:
isInteractiveInvocation(argv) && Boolean(process.stdout.isTTY),
screenReader:
argv.screenReader ??
settings.merged.ui?.accessibility?.screenReader ??
false,
protocolMode: Boolean(
isAcpMode ||
argv.inputFormat === InputFormat.STREAM_JSON ||
(argv.inputFile ?? settings.merged.dualOutput?.inputFile) ||
argv.jsonFd !== undefined,
),
sandbox: Boolean(sandboxConfig),
});
const customSandboxImage =
argv.sandboxImage ??
process.env['QWEN_SANDBOX_IMAGE'] ??
@@ -853,6 +870,7 @@ export async function main() {
);
process.exit(0);
} else if (
!npmBunHandoff &&
memoryArgs.length === 0 &&
!hasLoadedEnvironmentValues() &&
!isAcpMode &&
@@ -870,8 +888,13 @@ export async function main() {
// Interactive and streaming modes keep a supervisor for in-session
// restarts. A one-shot prompt can replace this already-loaded process.
await relaunchAppInChildProcess(memoryArgs, [], {
interpreter: npmBunHandoff?.interpreter,
afterSpawn: clearCorruptionEnvVars,
childEnv: { ...privateAcpChildEnv, ...getRelaunchEnvProvenance() },
childEnv: {
...privateAcpChildEnv,
...getRelaunchEnvProvenance(),
...npmBunHandoff?.childEnv,
},
environmentChangedSinceBoot: hasLoadedEnvironmentValues(),
onUpdateRelaunch,
replaceProcess:
@@ -19,6 +19,7 @@ import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
import { performStandaloneUpdate } from './standalone-update.js';
import { MessageType } from './types.js';
import os from 'node:os';
import * as npmRuntime from '../utils/npm-runtime.js';
const { mockT, identityT } = vi.hoisted(() => {
const identityT = (message: string, values?: Record<string, unknown>) =>
@@ -252,6 +253,26 @@ describe('handleAutoUpdate', () => {
);
});
it('starts a detached npm update worker with the original Node interpreter', () => {
vi.spyOn(npmRuntime, 'getNpmNodePath').mockReturnValue('/original/node');
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm install -g @qwen-code/qwen-code@latest',
isGlobal: true,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockSpawn).toHaveBeenCalledWith(
'/original/node',
[process.argv[1]],
expect.objectContaining({
detached: true,
env: expect.objectContaining({
QWEN_CODE_MANAGED_NPM_UPDATE_VERSION: '2.0.0',
}),
}),
);
});
it('runs npm through the active Node.js runtime on Windows', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
mockGetInstallationInfo.mockReturnValue({
+2 -1
View File
@@ -22,6 +22,7 @@ import { t } from '../i18n/index.js';
import type { spawn } from 'node:child_process';
import os from 'node:os';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { getNpmNodePath } from '../utils/npm-runtime.js';
const debugLogger = createDebugLogger('AUTO_UPDATE');
@@ -121,7 +122,7 @@ export async function handleAutoUpdate(
const isManagedNpmUpdate =
installationInfo.packageManager === PackageManager.NPM;
const command = isManagedNpmUpdate
? process.execPath
? getNpmNodePath()
: isWindows
? 'cmd.exe'
: 'bash';
@@ -4,38 +4,41 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
isOpenTuiRuntimeSupported,
MIN_BUN_VERSION,
MIN_NODE_VERSION,
OPEN_TUI_RENDERER_VALUE,
TUI_RENDERER_ENV_VAR,
TUI_RENDERER_STRICT_ENV_VAR,
type RuntimeVersionProbe,
} from '../../utils/tui-runtime.js';
export {
compareVersions,
isOpenTuiRuntimeSupported,
OPEN_TUI_RENDERER_VALUE,
parseVersion,
TUI_RENDERER_ENV_VAR,
TUI_RENDERER_STRICT_ENV_VAR,
} from '../../utils/tui-runtime.js';
export type { RuntimeVersionProbe } from '../../utils/tui-runtime.js';
/**
* Renderer selection for the OpenTUI migration (Batch 6).
*
* This is the single place that reads `QWEN_TUI_RENDERER` and decides whether
* the interactive entry mounts the OpenTUI backend or the ink tree. It is
* This decides whether the interactive entry mounts OpenTUI or the ink tree.
* The npm startup handoff reads the same explicit request before relaunching
* a supported installation into its private Bun runtime. This gate is
* deliberately dependency-free — it must NOT import `@opentui/*` or any
* renderer module, because it runs on every interactive startup (including on
* runtimes where OpenTUI cannot load) and before the renderer of record is
* chosen. The actual `createCliRenderer` probe lives in the entry, which can
* fall back to ink if construction throws even when this gate said "opentui".
*
* `QWEN_TUI_RENDERER` is the first reader of the flag: nothing else in the
* repository references it, so the default renderer stays ink until this gate
* is both requested and supported.
* The default renderer stays ink until OpenTUI is both requested and supported.
*/
/** The environment variable that selects the TUI renderer. */
export const TUI_RENDERER_ENV_VAR = 'QWEN_TUI_RENDERER';
/**
* Turns the ink fallback into a hard failure. The silent fallback is the
* right user-facing behavior, but the renderer-matrix E2E legs need "green
* run really exercised opentui": with this set, an explicit opentui request
* that cannot be served — the runtime cannot load the native renderer, or
* the OpenTUI entry fails to boot — throws instead of serving ink. Never set
* it in product defaults.
*/
export const TUI_RENDERER_STRICT_ENV_VAR = 'QWEN_TUI_RENDERER_STRICT';
/** The only non-default renderer value this gate recognizes. */
export const OPEN_TUI_RENDERER_VALUE = 'opentui';
export type TuiRendererChoice = 'ink' | 'opentui';
export interface RendererSelection {
@@ -50,73 +53,6 @@ export interface RendererSelection {
strict: boolean;
}
/** Minimum runtime versions that can initialize the OpenTUI native FFI. */
const MIN_BUN_VERSION = '1.3.0';
const MIN_NODE_VERSION = '26.4.0';
/**
* Parses a dotted version string into its numeric segments. Non-numeric or
* missing segments stop the parse; a leading `v` is tolerated.
*/
export function parseVersion(version: string): number[] {
const cleaned = version.trim().replace(/^v/i, '');
const segments: number[] = [];
for (const part of cleaned.split('.')) {
// A segment like "3-beta" contributes its leading integer only.
const match = /^(\d+)/.exec(part);
if (!match) break;
segments.push(Number(match[1]));
}
return segments;
}
/**
* Returns -1 when a < b, 0 when equal, 1 when a > b. Numerically equal
* versions break the tie semver-style: a pre-release sorts below its release
* (`1.3.0-beta < 1.3.0`), so a floor of `1.3.0` rejects `1.3.0-beta`.
*/
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const as = parseVersion(a);
const bs = parseVersion(b);
const length = Math.max(as.length, bs.length);
for (let i = 0; i < length; i++) {
const left = as[i] ?? 0;
const right = bs[i] ?? 0;
if (left !== right) return left < right ? -1 : 1;
}
const aPre = a.includes('-');
const bPre = b.includes('-');
if (aPre !== bPre) return aPre ? -1 : 1;
return 0;
}
export interface RuntimeVersionProbe {
/** e.g. "1.3.14" when running under Bun, otherwise undefined. */
bun?: string;
/** e.g. "26.4.0" when running under Node, otherwise undefined. */
node?: string;
}
/**
* Whether the current runtime can initialize the OpenTUI native FFI. Bun >=
* 1.3.0 and Node >= 26.4.0 (with `--experimental-ffi`) are the supported
* floors; anything older returns false so the entry stays on ink.
*/
export function isOpenTuiRuntimeSupported(
probe: RuntimeVersionProbe = {
bun: process.versions['bun'],
node: process.versions['node'],
},
): boolean {
if (probe.bun) {
return compareVersions(probe.bun, MIN_BUN_VERSION) >= 0;
}
if (probe.node) {
return compareVersions(probe.node, MIN_NODE_VERSION) >= 0;
}
return false;
}
/**
* Chooses the renderer for an interactive session.
*
@@ -633,6 +633,45 @@ describe('startOpenTuiUI fallback contract', () => {
expect(config.trackSessionRegistration).toHaveBeenCalled();
});
it('leaves SIGINT confirmation to the application and destroys during exit cleanup', async () => {
expect(
await startOpenTuiUI(
buildConfig(),
settings,
[],
'/tmp/project',
{} as InitializationResult,
),
).toBe(true);
expect(createCliRenderer).toHaveBeenLastCalledWith(
expect.objectContaining({
exitOnCtrlC: false,
exitSignals: [
'SIGTERM',
'SIGQUIT',
'SIGABRT',
'SIGHUP',
'SIGPIPE',
'SIGBREAK',
'SIGBUS',
],
}),
);
expect(mocks.state.renderer.destroy).not.toHaveBeenCalled();
for (const cleanup of mocks.state.cleanups) await cleanup();
expect(mocks.state.root.unmount).toHaveBeenCalledTimes(1);
expect(mocks.state.renderer.destroy).toHaveBeenCalledTimes(1);
expect(mocks.state.runtime.shutdown).toHaveBeenCalledTimes(1);
expect(mocks.state.root.unmount.mock.invocationCallOrder[0]).toBeLessThan(
mocks.state.renderer.destroy.mock.invocationCallOrder[0],
);
expect(
mocks.state.renderer.destroy.mock.invocationCallOrder[0],
).toBeLessThan(mocks.state.runtime.shutdown.mock.invocationCallOrder[0]);
});
it('threads the boot auth auto-open into the shell (U-6)', async () => {
// Unauthenticated: ink useAuth opens the dialog with no error message.
expect(
@@ -469,6 +469,17 @@ export async function startOpenTuiUI(
try {
renderer = await createCliRenderer({
exitOnCtrlC: false,
// The shared SIGINT handler confirms exit before running renderer cleanup.
// Preserve the other OpenTUI 0.5.10 defaults.
exitSignals: [
'SIGTERM',
'SIGQUIT',
'SIGABRT',
'SIGHUP',
'SIGPIPE',
'SIGBREAK',
'SIGBUS',
],
// ink's ui.mouseTracking gate: off means no SGR mouse mode, so the
// terminal keeps native right-click menus and OSC 8 link clicks.
useMouse: settings.merged.ui?.mouseTracking !== false,
@@ -6,6 +6,7 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import * as installationInfo from '../../utils/installationInfo.js';
import * as npmRuntime from '../../utils/npm-runtime.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -217,6 +218,30 @@ describe('checkForUpdates', () => {
});
});
it('uses the original Node identity for both npm executable and CLI resolution', async () => {
vi.spyOn(npmRuntime, 'getNpmNodePath').mockReturnValue('/original/node');
const run = vi.fn().mockResolvedValue({ stdout: '"1.1.0"\n', stderr: '' });
const resolveNpm = vi.fn().mockReturnValue('/original/npm-cli.js');
await expect(
runGlobalNpm(
['view', '@qwen-code/qwen-code', '--global'],
run as unknown as NonNullable<Parameters<typeof runGlobalNpm>[1]>,
'linux',
undefined,
resolveNpm,
),
).resolves.toBe('"1.1.0"');
expect(resolveNpm).toHaveBeenCalledExactlyOnceWith(
'/original/node',
'linux',
);
expect(run).toHaveBeenCalledWith(
'/original/node',
['/original/npm-cli.js', 'view', '@qwen-code/qwen-code', '--global'],
expect.objectContaining({ timeout: FETCH_TIMEOUT_MS }),
);
});
it('checks npm updates in the global npm context', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '"1.1.0"\n',
+2 -1
View File
@@ -16,6 +16,7 @@ import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { isBunRuntime } from '@qwen-code/qwen-code-core/utils/runtime-info.js';
import { t } from '../../i18n/index.js';
import { getGlobalNpmRegistryConfig } from '../../utils/npm-registry-config.js';
import { getNpmNodePath } from '../../utils/npm-runtime.js';
const debugLogger = createDebugLogger('UPDATE_CHECK');
@@ -151,7 +152,7 @@ export async function runGlobalNpm(
args: string[],
run: typeof execFileAsync = execFileAsync,
platform = process.platform,
nodePath = process.execPath,
nodePath = getNpmNodePath(),
resolveNpmCliPath = getNpmCliPath,
): Promise<string> {
const { stdout } = await run(
+2
View File
@@ -9,3 +9,5 @@ export const PRIVATE_RELAUNCH_ENV_PROVENANCE =
// Set only on a child that a relaunch supervisor spawned with an IPC channel.
export const RELAUNCH_SUPERVISED_ENV = 'QWEN_CODE_RELAUNCH_SUPERVISED';
export const NPM_RUNTIME_CONTEXT = 'QWEN_CODE_NPM_RUNTIME_CONTEXT';
+2 -1
View File
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as childProcess from 'node:child_process';
import { promisify } from 'node:util';
import { getNpmNodePath } from './npm-runtime.js';
export enum PackageManager {
NPM = 'npm',
@@ -24,7 +25,7 @@ export enum PackageManager {
}
export function getNpmCliPath(
nodePath = process.execPath,
nodePath = getNpmNodePath(),
platform = process.platform,
): string {
if (platform === 'win32') {
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export function isInteractiveInvocation(
args: {
promptInteractive?: string;
query?: string;
prompt?: string;
},
stdinIsTTY = process.stdin.isTTY ?? false,
): boolean {
return Boolean(
args.promptInteractive || (!args.query && !args.prompt && stdinIsTTY),
);
}
@@ -19,6 +19,7 @@ import { EventEmitter } from 'node:events';
import type { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mockCompromisedLock } from '../test-utils/mock-compromised-lock.js';
import * as npmRuntime from './npm-runtime.js';
const temporaryDirectories: string[] = [];
@@ -126,6 +127,41 @@ afterEach(() => {
});
describe('managed npm update', () => {
it('installs with the original Node interpreter after a Bun handoff', async () => {
const root = makeTemporaryDirectory();
const bootstrap = writeBaseInstallation(root);
const originalNode = path.join(root, 'node', 'bin', 'node');
const nodePath = vi
.spyOn(npmRuntime, 'getNpmNodePath')
.mockReturnValue(originalNode);
const spawnFn = vi.fn(
(_command: string, args: readonly string[]): ReturnType<typeof spawn> => {
writeInstallation(args[args.indexOf('--prefix') + 1]!, '2.0.0');
const child = new EventEmitter();
queueMicrotask(() => child.emit('close', 0));
return child as ReturnType<typeof spawn>;
},
);
try {
await stageManagedNpmUpdate(
'2.0.0',
bootstrap,
path.join(root, 'updates'),
spawnFn as unknown as typeof spawn,
);
expect(spawnFn).toHaveBeenCalledWith(
originalNode,
expect.arrayContaining([
expect.stringMatching(/npm-cli\.js$/),
'install',
]),
expect.objectContaining({ windowsHide: true }),
);
} finally {
nodePath.mockRestore();
}
});
it('downloads and validates a staged payload without activating it', async () => {
const root = makeTemporaryDirectory();
const bootstrap = writeBaseInstallation(root);
+8 -6
View File
@@ -15,6 +15,7 @@ import { promisify } from 'node:util';
import lockfile from 'proper-lockfile';
import semver from 'semver';
import { getNpmCliPath } from './installationInfo.js';
import { getNpmNodePath } from './npm-runtime.js';
const PACKAGE_NAME = '@qwen-code/qwen-code';
const debugLogger = createDebugLogger('MANAGED_NPM_UPDATE');
@@ -115,9 +116,10 @@ function cleanupOrphanedManagedNpmUpdateArtifacts(
function resolveNpmGlobalConfigPath(): string {
const configured = process.env['NPM_CONFIG_GLOBALCONFIG'];
if (configured) return path.resolve(configured);
const npmCliPath = getNpmCliPath(process.execPath, process.platform);
const nodePath = getNpmNodePath();
const npmCliPath = getNpmCliPath(nodePath, process.platform);
const runNpm = (args: string[]) =>
execFileSync(process.execPath, [npmCliPath, ...args], {
execFileSync(nodePath, [npmCliPath, ...args], {
encoding: 'utf8',
timeout: 10_000,
stdio: ['ignore', 'pipe', 'pipe'],
@@ -134,7 +136,7 @@ function resolveNpmGlobalConfigPath(): string {
'exec',
'--offline',
'-c',
`"${process.execPath}" -p "process.env.npm_config_globalconfig || require('path').resolve(process.env.npm_config_global_prefix, 'etc', 'npmrc')"`,
`"${nodePath}" -p "process.env.npm_config_globalconfig || require('path').resolve(process.env.npm_config_global_prefix, 'etc', 'npmrc')"`,
])
.split(/\r?\n/)
.at(-1) ?? '';
@@ -185,7 +187,7 @@ async function smokeTest(prefix: string): Promise<void> {
delete env['CLI_VERSION'];
delete env['QWEN_CODE_RELAUNCH_ARGS'];
await execFileAsync(
process.execPath,
getNpmNodePath(),
[path.join(packageDir(prefix), 'cli-entry.js'), '--help'],
{ encoding: 'utf8', env, timeout: 10_000 },
);
@@ -326,9 +328,9 @@ export async function stageManagedNpmUpdate(
try {
await new Promise<void>((resolve, reject) => {
const child = spawnFn(
process.execPath,
getNpmNodePath(),
[
getNpmCliPath(process.execPath, process.platform),
getNpmCliPath(getNpmNodePath(), process.platform),
...update.installArgs,
],
{
+323
View File
@@ -0,0 +1,323 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
chmodSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { selectNpmBunHandoff } from './npm-runtime.js';
import { isInteractiveInvocation } from './interactive-mode.js';
import { NPM_RUNTIME_CONTEXT } from './env-provenance.js';
const roots: string[] = [];
const originalVersions = Object.getOwnPropertyDescriptor(process, 'versions')!;
const originalExecPath = process.execPath;
const originalArgv = process.argv;
afterEach(() => {
process.execPath = originalExecPath;
process.argv = originalArgv;
Object.defineProperty(process, 'versions', originalVersions);
vi.unstubAllEnvs();
vi.restoreAllMocks();
for (const root of roots.splice(0))
rmSync(root, { recursive: true, force: true });
});
function file(path: string, content = '#!/bin/sh\nexit 0\n'): string {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, { mode: 0o755 });
return path;
}
function fixture({ nested = false, platform = 'darwin', arch = 'arm64' } = {}) {
const outer = mkdtempSync(join(tmpdir(), 'qwen-npm-runtime-'));
roots.push(outer);
const root = join(outer, 'node_modules', '@qwen-code', 'qwen-code');
const name = `@oven/bun-${platform}-${arch === 'arm64' ? 'aarch64' : 'x64'}`;
const manifest = {
name: '@qwen-code/qwen-code',
bin: { qwen: 'cli-entry.js' },
optionalDependencies: { [name]: '1.3.14' },
};
file(join(root, 'package.json'), JSON.stringify(manifest));
const script = file(join(root, 'cli.js'));
file(join(root, 'cli-entry.js'));
const dependency = join(nested ? root : outer, 'node_modules', name);
file(
join(dependency, 'package.json'),
JSON.stringify({ name, version: '1.3.14' }),
);
const bun = file(join(dependency, 'bin', 'bun'));
const options = {
interactive: true,
screenReader: false,
protocolMode: false,
sandbox: false,
env: { QWEN_TUI_RENDERER: 'opentui' },
versions: { node: '24.19.0' },
execArgv: [],
platform,
arch,
script,
glibc: true,
};
return { outer, root, script, name, bun, dependency, manifest, options };
}
describe('npm Bun runtime admission', () => {
it.each([
['darwin', 'arm64', false],
['darwin', 'x64', true],
['linux', 'arm64', true],
['linux', 'x64', false],
] as const)(
'uses only the installed %s-%s runtime (nested=%s)',
(platform, arch, nested) => {
const f = fixture({ platform, arch, nested });
const result = selectNpmBunHandoff(f.options);
expect(result?.interpreter).toEqual({
command: realpathSync(f.bun),
runtime: 'bun',
});
expect(JSON.parse(result!.childEnv[NPM_RUNTIME_CONTEXT]!)).toEqual({
node: realpathSync(process.execPath),
bun: realpathSync(f.bun),
cli: realpathSync(f.script),
});
},
);
const unavailableCases: Array<
[string, Partial<Parameters<typeof selectNpmBunHandoff>[0]>]
> = [
['Windows', { platform: 'win32' }],
['unknown architecture', { arch: 'riscv64' }],
['musl report', { platform: 'linux', glibc: false }],
['musl override', { env: { OPENTUI_LIBC: 'musl' } }],
['disabled relaunch', { env: { QWEN_CODE_NO_RELAUNCH: 'true' } }],
['Node loader', { execArgv: ['--import', 'loader.js'] }],
['Node memory flag', { execArgv: ['--max-old-space-size=4096'] }],
['NODE_OPTIONS', { env: { NODE_OPTIONS: '--trace-warnings' } }],
];
it.each([
['default renderer', { env: {} }],
['Ink', { env: { QWEN_TUI_RENDERER: 'ink' } }],
['unknown renderer', { env: { QWEN_TUI_RENDERER: 'other' } }],
['headless or pipe', { interactive: false }],
['screen reader', { screenReader: true }],
['ACP/SDK/IDE or dual-output protocol', { protocolMode: true }],
['whole-CLI sandbox', { sandbox: true }],
['inside sandbox', { env: { QWEN_TUI_RENDERER: 'opentui', SANDBOX: '1' } }],
['already Bun', { versions: { bun: '1.3.14', node: '24.3.0' } }],
['Node 26.4', { versions: { node: '26.4.0' } }],
['newer Node', { versions: { node: '27.0.0' } }],
])('does not hand off %s', (_name, override) => {
const f = fixture();
expect(selectNpmBunHandoff({ ...f.options, ...override })).toBeUndefined();
});
it.each(unavailableCases)(
'falls back or fails strict for %s',
(_name, override) => {
const f = fixture();
const options = {
...f.options,
...override,
env: { ...f.options.env, ...override.env },
};
expect(selectNpmBunHandoff(options)).toBeUndefined();
expect(() =>
selectNpmBunHandoff({
...options,
env: { ...options.env, QWEN_TUI_RENDERER_STRICT: 'true' },
}),
).toThrow('OpenTUI npm Bun handoff unavailable');
},
);
it('does not probe the host report for an ordinary startup', () => {
const report = vi.spyOn(process.report, 'getReport');
const f = fixture();
expect(
selectNpmBunHandoff({
...f.options,
glibc: undefined,
platform: 'linux',
env: {},
}),
).toBeUndefined();
expect(report).not.toHaveBeenCalled();
});
it('accepts expose-gc without forwarding arbitrary Node flags', () => {
const f = fixture();
expect(
selectNpmBunHandoff({ ...f.options, execArgv: ['--expose-gc'] })
?.interpreter.runtime,
).toBe('bun');
});
it.each(['missing', 'wrong version', 'wrong name', 'unpinned'])(
'rejects an optional runtime that is %s',
(problem) => {
const f = fixture();
if (problem === 'missing') rmSync(f.dependency, { recursive: true });
if (problem === 'wrong version' || problem === 'wrong name') {
file(
join(f.dependency, 'package.json'),
JSON.stringify({
name: problem === 'wrong name' ? '@other/bun' : f.name,
version: problem === 'wrong version' ? '1.4.0' : '1.3.14',
}),
);
}
if (problem === 'unpinned') {
file(
join(f.root, 'package.json'),
JSON.stringify({
...f.manifest,
optionalDependencies: { [f.name]: '^1.3.14' },
}),
);
}
expect(selectNpmBunHandoff(f.options)).toBeUndefined();
expect(() =>
selectNpmBunHandoff({
...f.options,
env: { ...f.options.env, QWEN_TUI_RENDERER_STRICT: '1' },
}),
).toThrow('install the published optional dependency');
},
);
it.skipIf(process.platform === 'win32')(
'rejects a non-executable private runtime',
() => {
const f = fixture();
chmodSync(f.bun, 0o644);
expect(selectNpmBunHandoff(f.options)).toBeUndefined();
},
);
it('does not use a dependency found above this installation', () => {
const f = fixture();
const nestedRoot = join(
f.outer,
'project',
'node_modules',
'@qwen-code',
'qwen-code',
);
file(join(nestedRoot, 'package.json'), JSON.stringify(f.manifest));
const script = file(join(nestedRoot, 'cli.js'));
expect(selectNpmBunHandoff({ ...f.options, script })).toBeUndefined();
});
it('does not turn source or standalone layouts into npm installs', () => {
const f = fixture();
for (const suffix of ['dist', 'standalone/lib']) {
const root = join(f.outer, suffix);
file(join(root, 'package.json'), JSON.stringify(f.manifest));
const script = file(join(root, 'cli.js'));
expect(
selectNpmBunHandoff({
...f.options,
script,
env: { ...f.options.env, QWEN_TUI_RENDERER_STRICT: '1' },
}),
).toBeUndefined();
}
});
});
describe('npm interpreter identity', () => {
async function bootBun(context: string, f: ReturnType<typeof fixture>) {
process.execPath = f.bun;
process.argv = [f.bun, f.script];
Object.defineProperty(process, 'versions', {
value: { ...process.versions, bun: '1.3.14' },
configurable: true,
});
vi.stubEnv(NPM_RUNTIME_CONTEXT, context);
vi.resetModules();
return import('./npm-runtime.js');
}
it('captures original Node before env changes and keeps application identity intact', async () => {
const f = fixture();
const childEnv = selectNpmBunHandoff(f.options)!.childEnv;
const runtime = await bootBun(childEnv[NPM_RUNTIME_CONTEXT]!, f);
expect(runtime.getNpmNodePath()).toBe(realpathSync(originalExecPath));
process.env[NPM_RUNTIME_CONTEXT] = JSON.stringify({
node: f.bun,
bun: f.bun,
cli: f.script,
});
expect(runtime.getNpmNodePath()).toBe(realpathSync(originalExecPath));
expect(process.execPath).toBe(f.bun);
expect(process.argv[1]).toBe(f.script);
});
it('keeps a removed original Node path so npm cannot silently switch to Bun', async () => {
const f = fixture();
const removedNode = join(f.outer, 'removed-node');
const runtime = await bootBun(
JSON.stringify({
node: removedNode,
cli: realpathSync(f.script),
bun: realpathSync(f.bun),
}),
f,
);
expect(runtime.getNpmNodePath()).toBe(removedNode);
});
it.each(['malformed', 'different CLI', 'different Bun'])(
'does not accept %s context',
async (problem) => {
const f = fixture();
const context = {
node: originalExecPath,
cli:
problem === 'different CLI'
? join(f.outer, 'other.js')
: realpathSync(f.script),
bun:
problem === 'different Bun' ? originalExecPath : realpathSync(f.bun),
};
const runtime = await bootBun(
problem === 'malformed' ? 'invalid' : JSON.stringify(context),
f,
);
expect(runtime.getNpmNodePath()).toBe(f.bun);
},
);
});
describe('shared interactive mode', () => {
it.each([
[{}, true, true],
[{}, false, false],
[{ query: 'hello' }, true, false],
[{ prompt: 'hello' }, true, false],
[{ promptInteractive: 'hello' }, true, true],
[{ prompt: 'hello', promptInteractive: 'continue' }, true, true],
[{ promptInteractive: '' }, false, false],
] as const)(
'matches configuration for %j / tty=%s',
(args, tty, expected) => {
expect(isInteractiveInvocation(args, tty)).toBe(expected);
},
);
});
+231
View File
@@ -0,0 +1,231 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
accessSync,
constants,
readFileSync,
realpathSync,
statSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { basename, dirname, isAbsolute, join } from 'node:path';
import type { JsInterpreterCommand } from '@qwen-code/qwen-code-core/utils/runtime-info.js';
import { NPM_RUNTIME_CONTEXT } from './env-provenance.js';
import {
isOpenTuiRuntimeSupported,
OPEN_TUI_RENDERER_VALUE,
TUI_RENDERER_ENV_VAR,
TUI_RENDERER_STRICT_ENV_VAR,
} from './tui-runtime.js';
const BUN_PACKAGES: Readonly<Record<string, string>> = {
'darwin-arm64': '@oven/bun-darwin-aarch64',
'darwin-x64': '@oven/bun-darwin-x64',
'linux-arm64': '@oven/bun-linux-aarch64',
'linux-x64': '@oven/bun-linux-x64',
};
function executableFile(file: string): string {
const realPath = realpathSync(file);
if (!statSync(realPath).isFile())
throw new Error('Expected an executable file');
accessSync(realPath, constants.X_OK);
return realPath;
}
function npmPackageRoot(script: string): string {
const root = dirname(realpathSync(script));
const manifest = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8'),
) as {
name?: unknown;
bin?: { qwen?: unknown };
};
if (
basename(script) !== 'cli.js' ||
manifest.name !== '@qwen-code/qwen-code' ||
manifest.bin?.qwen !== 'cli-entry.js' ||
basename(dirname(root)) !== '@qwen-code' ||
basename(dirname(dirname(root))) !== 'node_modules'
) {
throw new Error('The running CLI is not a published npm installation');
}
return root;
}
function npmNodeAtBoot(): string {
if (!process.versions['bun']) return process.execPath;
try {
const context: unknown = JSON.parse(process.env[NPM_RUNTIME_CONTEXT] ?? '');
if (!context || typeof context !== 'object') return process.execPath;
const entry = context as Record<string, unknown>;
if (
typeof entry['node'] !== 'string' ||
typeof entry['bun'] !== 'string' ||
typeof entry['cli'] !== 'string' ||
!isAbsolute(entry['node']) ||
entry['cli'] !== realpathSync(process.argv[1]!) ||
entry['bun'] !== realpathSync(process.execPath)
) {
return process.execPath;
}
npmPackageRoot(entry['cli']);
// The parent validated this executable. If it is later removed, npm must
// fail at that Node path rather than silently executing through private Bun.
return entry['node'];
} catch {
return process.execPath;
}
}
// Capture before project settings/env can replace the launcher's private context.
const npmNodePath = npmNodeAtBoot();
export function getNpmNodePath(): string {
return npmNodePath;
}
export interface NpmBunHandoff {
interpreter: JsInterpreterCommand;
childEnv: Readonly<Record<string, string>>;
}
export function selectNpmBunHandoff({
interactive,
screenReader,
protocolMode,
sandbox,
env = process.env,
script = process.argv[1],
platform = process.platform,
arch = process.arch,
versions = process.versions,
execArgv = process.execArgv,
glibc,
}: {
interactive: boolean;
screenReader: boolean;
protocolMode: boolean;
sandbox: boolean;
env?: NodeJS.ProcessEnv;
script?: string;
platform?: string;
arch?: string;
versions?: { bun?: string; node?: string };
execArgv?: readonly string[];
glibc?: boolean;
}): NpmBunHandoff | undefined {
if (
env[TUI_RENDERER_ENV_VAR]?.trim().toLowerCase() !==
OPEN_TUI_RENDERER_VALUE ||
!interactive ||
screenReader ||
protocolMode ||
sandbox ||
env['SANDBOX'] ||
versions.bun ||
isOpenTuiRuntimeSupported(versions)
) {
return undefined;
}
const unavailable = (reason: string): undefined => {
if (
['1', 'true'].includes(
env[TUI_RENDERER_STRICT_ENV_VAR]?.trim().toLowerCase() ?? '',
)
) {
throw new Error(`OpenTUI npm Bun handoff unavailable: ${reason}`);
}
return undefined;
};
const name = BUN_PACKAGES[`${platform}-${arch}`];
const supportedLibc =
platform !== 'linux' ||
(glibc ??
Boolean(
(
process.report?.getReport() as {
header?: { glibcVersionRuntime?: string };
}
)?.header?.glibcVersionRuntime,
));
if (!name || !supportedLibc || env['OPENTUI_LIBC'] === 'musl') {
return unavailable('only macOS and glibc Linux arm64/x64 are supported');
}
if (env['QWEN_CODE_NO_RELAUNCH']) {
return unavailable('QWEN_CODE_NO_RELAUNCH disables runtime handoff');
}
if (
execArgv.some((arg) => arg !== '--expose-gc') ||
env['NODE_OPTIONS']?.trim()
) {
return unavailable('inherited Node runtime flags require the Node runtime');
}
if (!script) return undefined;
let root: string;
try {
root = npmPackageRoot(script);
} catch {
// Source and standalone installations keep their existing runtime policy.
return undefined;
}
try {
const manifest = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8'),
) as {
optionalDependencies?: Record<string, string>;
};
const version = manifest.optionalDependencies?.[name];
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`no exact optional dependency pin for ${name}`);
}
const dependencyManifest = createRequire(
join(root, 'package.json'),
).resolve(`${name}/package.json`);
const dependencyRoot = dirname(realpathSync(dependencyManifest));
const installed = JSON.parse(readFileSync(dependencyManifest, 'utf8')) as {
name?: unknown;
version?: unknown;
};
const ownedDependency = [
join(root, 'node_modules', name),
join(dirname(dirname(root)), name),
].some((candidate) => {
try {
return realpathSync(candidate) === dependencyRoot;
} catch {
return false;
}
});
if (
installed.name !== name ||
installed.version !== version ||
!ownedDependency
) {
throw new Error(
`${name} does not match this installation's pinned runtime`,
);
}
const command = executableFile(join(dependencyRoot, 'bin', 'bun'));
return {
interpreter: { command, runtime: 'bun' },
childEnv: {
[NPM_RUNTIME_CONTEXT]: JSON.stringify({
node: executableFile(process.execPath),
bun: command,
cli: realpathSync(script),
}),
},
};
} catch (error) {
return unavailable(
`install the published optional dependency ${name}; ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
+166 -22
View File
@@ -205,6 +205,111 @@ describe('relaunchAppInChildProcess', () => {
});
});
it('keeps Node supervising private Bun without forwarding Node memory or loader flags', async () => {
process.execArgv = ['--import', 'node-loader.js'];
process.argv = ['/usr/bin/node', '/installed/cli.js', '-i', 'hello'];
const execveSpy = vi.fn(() => undefined as never);
process.execve = execveSpy;
const child = createMockChildProcess(0, false);
mockedSpawn.mockReturnValue(child);
const onUpdateRelaunch = vi.fn().mockResolvedValue(44);
const promise = relaunchAppInChildProcess(
['--max-old-space-size=4096'],
[],
{
interpreter: { command: '/installed/private/bun', runtime: 'bun' },
childEnv: { QWEN_CODE_NPM_RUNTIME_CONTEXT: 'private-context' },
replaceProcess: true,
onUpdateRelaunch,
},
);
await vi.waitFor(() => expect(mockedSpawn).toHaveBeenCalledOnce());
expect(mockedSpawn).toHaveBeenCalledWith(
'/installed/private/bun',
['--expose-gc', '/installed/cli.js', '-i', 'hello'],
expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: expect.objectContaining({
QWEN_CODE_NO_RELAUNCH: 'true',
QWEN_CODE_NPM_RUNTIME_CONTEXT: 'private-context',
QWEN_CODE_RELAUNCH_SUPERVISED: '1',
}),
}),
);
expect(execveSpy).not.toHaveBeenCalled();
child.emit('message', { type: UPDATE_ON_EXIT_MESSAGE });
child.emit('close', 0);
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(onUpdateRelaunch).toHaveBeenCalledExactlyOnceWith(false);
expect(processExitSpy).toHaveBeenCalledWith(44);
});
it.each([
{ runtime: 'bun' as const, tty: true, marker: 'terminal' },
{ runtime: 'bun' as const, tty: false, marker: '1' },
{ runtime: 'node' as const, tty: true, marker: '1' },
])(
'keeps SIGINT ownership in the child only for $runtime tty=$tty',
async ({ runtime, tty, marker }) => {
vi.mocked(isatty).mockReturnValue(tty);
const before = process.listeners('SIGINT');
onTestFinished(() => {
for (const listener of process.listeners('SIGINT')) {
if (!before.includes(listener))
process.removeListener('SIGINT', listener);
}
});
const child = createMockChildProcess();
mockedSpawn.mockReturnValue(child);
const promise = relaunchAppInChildProcess([], [], {
interpreter: { command: `/installed/${runtime}`, runtime },
});
expect(mockedSpawn.mock.calls[0]?.[2]?.env).toHaveProperty(
'QWEN_CODE_RELAUNCH_SUPERVISED',
marker,
);
const added = process
.listeners('SIGINT')
.filter((fn) => !before.includes(fn));
expect(added).toHaveLength(marker === 'terminal' ? 1 : 0);
for (const listener of added) listener('SIGINT');
expect(processExitSpy).not.toHaveBeenCalled();
expect(child.kill).not.toHaveBeenCalled();
child.emit('close', 0);
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(process.listeners('SIGINT')).toEqual(before);
},
);
it.each(['spawn', 'error', 'afterSpawn'] as const)(
'removes the terminal supervisor signal handler on %s failure',
async (failure) => {
vi.mocked(isatty).mockReturnValue(true);
const before = process.listeners('SIGINT');
onTestFinished(() => {
for (const listener of process.listeners('SIGINT')) {
if (!before.includes(listener))
process.removeListener('SIGINT', listener);
}
});
const child = createMockChildProcess();
mockedSpawn.mockImplementation(() => {
if (failure === 'spawn') throw new Error('spawn failed');
return child;
});
const promise = relaunchAppInChildProcess([], [], {
interpreter: { command: '/installed/bun', runtime: 'bun' },
afterSpawn: () => {
if (failure === 'afterSpawn') throw new Error('afterSpawn failed');
},
});
if (failure === 'error') child.emit('error', new Error('spawn error'));
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(process.listeners('SIGINT')).toEqual(before);
if (failure === 'afterSpawn') expect(child.kill).toHaveBeenCalledOnce();
},
);
it('replaces the current process when requested and supported', async () => {
// Leaked into this process; a replaced image must not be marked.
process.env['QWEN_CODE_RELAUNCH_SUPERVISED'] = '1';
@@ -847,6 +952,41 @@ describe.skipIf(!process.channel)('exitWhenSupervisorExits', () => {
expect(unrefSpy).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});
it('cleans up the npm terminal child immediately when its IPC parent disappears', async () => {
process.env['QWEN_CODE_RELAUNCH_SUPERVISED'] = 'terminal';
vi.mocked(isatty).mockReturnValue(true);
exitWhenSupervisorExits();
expect(process.env['QWEN_CODE_RELAUNCH_SUPERVISED']).toBeUndefined();
expect(unrefSpy).toHaveBeenCalledOnce();
expect(listeners).toHaveLength(1);
expect(runExitCleanup).not.toHaveBeenCalled();
listeners[0]!();
expect(runExitCleanup).toHaveBeenCalledOnce();
await Promise.resolve();
expect(exitSpy).toHaveBeenCalledWith(129);
});
it('retains the full pipe grace period even if a terminal marker is inherited', async () => {
process.env['QWEN_CODE_RELAUNCH_SUPERVISED'] = 'terminal';
vi.useFakeTimers({ toFake: ['setTimeout'] });
try {
exitWhenSupervisorExits();
expect(process.env['QWEN_CODE_RELAUNCH_SUPERVISED']).toBeUndefined();
expect(listeners).toHaveLength(1);
listeners[0]!();
await vi.advanceTimersByTimeAsync(119_999);
expect(runExitCleanup).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(runExitCleanup).toHaveBeenCalledOnce();
expect(exitSpy).toHaveBeenCalledWith(129);
} finally {
vi.useRealTimers();
}
});
});
describe('a supervised child', () => {
@@ -947,28 +1087,32 @@ describe('a supervised child', () => {
expect(output()).toContain('exit:129');
}, 30_000);
it('ignores the marker without a channel from a supervisor', async () => {
const env = { ...process.env };
env['QWEN_CODE_NO_RELAUNCH'] = 'true';
env['QWEN_CODE_RELAUNCH_SUPERVISED'] = '1';
// Started directly, without an IPC channel, as a forged or leaked marker
// would be: it must run to its end instead of exiting as orphaned.
const code = await within(
new Promise<number | null>((resolve) => {
const child = execFile(
process.execPath,
['--import', 'tsx', helper, 'idle'],
{ env },
() => resolve(child.exitCode),
);
onTestFinished(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
}),
15_000,
);
expect(code).toBe(0);
}, 30_000);
it.each(['1', 'terminal'])(
'ignores marker %s without a channel from a supervisor',
async (marker) => {
const env = { ...process.env };
env['QWEN_CODE_NO_RELAUNCH'] = 'true';
env['QWEN_CODE_RELAUNCH_SUPERVISED'] = marker;
// Started directly, without an IPC channel, as a forged or leaked marker
// would be: it must run to its end instead of exiting as orphaned.
const code = await within(
new Promise<number | null>((resolve) => {
const child = execFile(
process.execPath,
['--import', 'tsx', helper, 'idle'],
{ env },
() => resolve(child.exitCode),
);
onTestFinished(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
}),
15_000,
);
expect(code).toBe(0);
},
30_000,
);
it('still exits on its own once its work is done', async () => {
const { exited } = await start(['idle']);
+38 -10
View File
@@ -7,6 +7,7 @@
import { spawn } from 'node:child_process';
import { isatty } from 'node:tty';
import { createDebugLogger } from '@qwen-code/qwen-code-core/utils/debugLogger.js';
import type { JsInterpreterCommand } from '@qwen-code/qwen-code-core/utils/runtime-info.js';
import {
RELAUNCH_EXIT_CODE,
UPDATE_ON_EXIT_MESSAGE,
@@ -26,6 +27,7 @@ const debugLogger = createDebugLogger('RELAUNCH');
const ORPHAN_GRACE_MS = 120_000;
interface RelaunchOptions {
interpreter?: JsInterpreterCommand;
afterSpawn?: () => void;
childEnv?: Readonly<Record<string, string>>;
onUpdateRelaunch?: (relaunchOnFailure: boolean) => Promise<number> | number;
@@ -71,7 +73,8 @@ export async function relaunchOnExitCode(
* for tests.
*/
export function exitWhenSupervisorExits(graceMs = ORPHAN_GRACE_MS): void {
if (process.env[RELAUNCH_SUPERVISED_ENV] !== '1') {
const supervision = process.env[RELAUNCH_SUPERVISED_ENV];
if (supervision !== '1' && supervision !== 'terminal') {
return;
}
// Tools and subagents this process spawns are not the supervisor's children.
@@ -81,10 +84,10 @@ export function exitWhenSupervisorExits(graceMs = ORPHAN_GRACE_MS): void {
if (typeof process.send !== 'function') {
return;
}
// On a terminal the process group gets the terminal's signals, and a shell
// that has taken the terminal back would make restoring it fail, so only a
// child on pipes (an SDK host, an editor, a daemon) watches.
if (isatty(0)) {
// The npm Bun handoff keeps a terminal child alive behind Node even when
// only Node is killed. Other terminal relaunches keep their existing policy.
const onTerminal = isatty(0);
if (onTerminal && supervision !== 'terminal') {
return;
}
// 129, as for SIGHUP: the side that started this process hung up.
@@ -95,6 +98,11 @@ export function exitWhenSupervisorExits(graceMs = ORPHAN_GRACE_MS): void {
return;
}
process.once('disconnect', () => {
if (onTerminal) {
debugLogger.debug('Terminal supervisor gone; exiting.');
exit();
return;
}
debugLogger.debug('Supervisor gone; exiting after the grace period.');
// Nothing is signalled here: a host that signals the process group, or
// closes this process's input, starts the mode's own shutdown, and a
@@ -117,9 +125,11 @@ export async function relaunchAppInChildProcess(
const script = process.argv[1];
const scriptArgs = process.argv.slice(2);
const command = options?.interpreter?.command ?? process.execPath;
const nodeArgs = [
...getRelaunchExecArgv(),
...additionalNodeArgs,
...(options?.interpreter?.runtime === 'bun'
? ['--expose-gc']
: [...getRelaunchExecArgv(), ...additionalNodeArgs]),
script,
...additionalScriptArgs,
...scriptArgs,
@@ -145,6 +155,7 @@ export async function relaunchAppInChildProcess(
// nothing for the hook to protect on this path.
if (
options?.replaceProcess &&
!options.interpreter &&
typeof process.execve === 'function' &&
!['win32', 'os400'].includes(process.platform)
) {
@@ -182,15 +193,27 @@ export async function relaunchAppInChildProcess(
const runner = () => {
let updateOnExitRequested = false;
const newEnv = { ...createChildEnv(), [RELAUNCH_SUPERVISED_ENV]: '1' };
const terminalHandoff =
options?.interpreter?.runtime === 'bun' && isatty(0);
const newEnv = {
...createChildEnv(),
[RELAUNCH_SUPERVISED_ENV]: terminalHandoff ? 'terminal' : '1',
};
// The parent process should not be reading from stdin while the child is running.
process.stdin.pause();
const child = spawn(process.execPath, nodeArgs, {
const child = spawn(command, nodeArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: newEnv,
});
// The terminal sends SIGINT to both processes; the interactive child owns
// its confirmation window. Exiting Node on the first signal would orphan it.
const keepSupervisor = () => {};
if (terminalHandoff) process.on('SIGINT', keepSupervisor);
const removeSignalHandler = () => {
if (terminalHandoff) process.removeListener('SIGINT', keepSupervisor);
};
child.on('message', (message) => {
if (
@@ -208,13 +231,18 @@ export async function relaunchAppInChildProcess(
try {
options?.afterSpawn?.();
} catch (err) {
removeSignalHandler();
child.kill();
throw err;
}
return new Promise<number>((resolve, reject) => {
child.on('error', reject);
child.on('error', (error) => {
removeSignalHandler();
reject(error);
});
child.on('close', (code) => {
removeSignalHandler();
// Resume stdin before the parent process exits.
process.stdin.resume();
const exitCode = code ?? 1;
+88
View File
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/** The environment variable that selects the TUI renderer. */
export const TUI_RENDERER_ENV_VAR = 'QWEN_TUI_RENDERER';
/**
* Turns the ink fallback into a hard failure. The silent fallback is the
* right user-facing behavior, but the renderer-matrix E2E legs need "green
* run really exercised opentui": with this set, an explicit opentui request
* that cannot be served — the runtime cannot load the native renderer, or
* the OpenTUI entry fails to boot — throws instead of serving ink. Never set
* it in product defaults.
*/
export const TUI_RENDERER_STRICT_ENV_VAR = 'QWEN_TUI_RENDERER_STRICT';
/** The only non-default renderer value this gate recognizes. */
export const OPEN_TUI_RENDERER_VALUE = 'opentui';
/** Minimum runtime versions that can initialize the OpenTUI native FFI. */
export const MIN_BUN_VERSION = '1.3.0';
export const MIN_NODE_VERSION = '26.4.0';
/**
* Parses a dotted version string into its numeric segments. Non-numeric or
* missing segments stop the parse; a leading `v` is tolerated.
*/
export function parseVersion(version: string): number[] {
const cleaned = version.trim().replace(/^v/i, '');
const segments: number[] = [];
for (const part of cleaned.split('.')) {
// A segment like "3-beta" contributes its leading integer only.
const match = /^(\d+)/.exec(part);
if (!match) break;
segments.push(Number(match[1]));
}
return segments;
}
/**
* Returns -1 when a < b, 0 when equal, 1 when a > b. Numerically equal
* versions break the tie semver-style: a pre-release sorts below its release
* (`1.3.0-beta < 1.3.0`), so a floor of `1.3.0` rejects `1.3.0-beta`.
*/
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const as = parseVersion(a);
const bs = parseVersion(b);
const length = Math.max(as.length, bs.length);
for (let i = 0; i < length; i++) {
const left = as[i] ?? 0;
const right = bs[i] ?? 0;
if (left !== right) return left < right ? -1 : 1;
}
const aPre = a.includes('-');
const bPre = b.includes('-');
if (aPre !== bPre) return aPre ? -1 : 1;
return 0;
}
export interface RuntimeVersionProbe {
/** e.g. "1.3.14" when running under Bun, otherwise undefined. */
bun?: string;
/** e.g. "26.4.0" when running under Node, otherwise undefined. */
node?: string;
}
/**
* Whether the current runtime can initialize the OpenTUI native FFI. Bun >=
* 1.3.0 and Node >= 26.4.0 (with `--experimental-ffi`) are the supported
* floors; anything older returns false so the entry stays on ink.
*/
export function isOpenTuiRuntimeSupported(
probe: RuntimeVersionProbe = {
bun: process.versions['bun'],
node: process.versions['node'],
},
): boolean {
if (probe.bun) {
return compareVersions(probe.bun, MIN_BUN_VERSION) >= 0;
}
if (probe.node) {
return compareVersions(probe.node, MIN_NODE_VERSION) >= 0;
}
return false;
}
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as runtimeInfo from '../utils/runtime-info.js';
import { spawn, spawnSync } from 'node:child_process';
import {
mkdir,
@@ -176,6 +177,26 @@ describe('unit creation and attachment', () => {
});
describe('managed command launcher environment', () => {
it('launches through the private interpreter while preserving the payload executable', () => {
const interpreter = join(directory, 'bun', 'bin', 'bun');
vi.spyOn(runtimeInfo, 'getJsInterpreterCommand').mockReturnValue({
command: interpreter,
runtime: 'bun',
});
const unit: HookCommandCgroup = Reflect.construct(HookCommandCgroup, [
directory,
]);
const launch = unit.launch('/payload/bin/tool', ['literal argument'], {
PATH: '/payload/bin',
});
expect(launch.executable).toBe(interpreter);
expect(launch.args.slice(-3)).toEqual([
directory,
'/payload/bin/tool',
'["literal argument"]',
]);
});
it('keeps environment values out of argv and applies them only after membership', async () => {
const preload = join(directory, 'preload.cjs');
const marker = join(directory, 'preloaded');
@@ -16,6 +16,7 @@ import {
writeFileSync,
} from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { getJsInterpreterCommand } from '../utils/runtime-info.js';
/**
* The recorded-refusal cause discriminator. The recorded refusal is the
@@ -270,7 +271,7 @@ export class HookCommandCgroup {
options?: { reapOnParentExit?: boolean },
) {
return {
executable: process.execPath,
executable: getJsInterpreterCommand(import.meta.url).command,
args: [
'--input-type=commonjs',
'--eval',
@@ -20,6 +20,7 @@ import {
import * as fs from 'node:fs';
import * as runnerPaths from './landlock-runner-path.js';
import os from 'node:os';
import * as runtimeInfo from '../utils/runtime-info.js';
import path from 'node:path';
import type {
ProcessLaunch,
@@ -151,6 +152,13 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
) => (await run(bwrapPolicy, launch)).result;
it('builds a literal launch, transports the payload env privately, and prepares writable masks', async () => {
const interpreter = path.join(installation, 'bun', 'bin', 'bun');
mkdirSync(path.dirname(interpreter), { recursive: true });
writeFileSync(interpreter, 'fixture');
vi.spyOn(runtimeInfo, 'getJsInterpreterCommand').mockReturnValue({
command: interpreter,
runtime: 'bun',
});
const maskedPath = path.join(workspace, '.qwen', 'review-leases');
const launch = mockLaunch({ state: 'confirmed', exitCode: 0 });
const handle = await run({ ...policy(), maskedPaths: [maskedPath] });
@@ -158,6 +166,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
sandboxStatus: { state: 'confirmed', exitCode: 0 },
});
const relayLaunch = launch.mock.calls[0][0];
expect(relayLaunch.executable).toBe(realpathSync(interpreter));
const argv = relayLaunch.args;
const admittedWorkspace = realpathSync(workspace);
const admittedMaskedPath = path.join(
@@ -196,6 +205,28 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => {
expect(readdirSync(state)).toEqual([]);
});
it('protects the private interpreter directory independently of bwrap and the relay', async () => {
const interpreter = path.join(root, 'private-runtime', 'bun', 'bin', 'bun');
mkdirSync(path.dirname(interpreter), { recursive: true });
writeFileSync(interpreter, 'fixture');
vi.spyOn(runtimeInfo, 'getJsInterpreterCommand').mockReturnValue({
command: interpreter,
runtime: 'bun',
});
const launch = vi.spyOn(ShellExecutionService, 'executeLaunch');
await expect(
run(
{ ...policy(), workspace: path.dirname(interpreter) },
{
...payload(),
cwd: path.dirname(interpreter),
},
),
).rejects.toThrow('Writable directory overlaps a protected root');
expect(launch).not.toHaveBeenCalled();
expect(readdirSync(state)).toEqual([]);
});
it('inherits redirected stdin through the relay launch', async () => {
const launch = mockLaunch({ state: 'confirmed', exitCode: 0 });
await runToResult(policy(), { ...payload(), inheritStdin: true });
+6 -3
View File
@@ -6,6 +6,7 @@
import { existsSync, mkdirSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { getJsInterpreterCommand } from '../utils/runtime-info.js';
import type {
ProcessLaunch,
ShellExecutionConfig,
@@ -38,7 +39,9 @@ export async function executeBwrap(
): Promise<SandboxExecutionHandle> {
const relay = sandboxAsset('bwrap-relay');
const inputBridge = payload.inheritStdin ? resolveStdinBridge() : undefined;
const node = realpathSync(process.execPath);
const interpreter = realpathSync(
getJsInterpreterCommand(import.meta.url).command,
);
const requestedBwrap = policy.bwrapPath ?? '/usr/bin/bwrap';
if (!path.isAbsolute(requestedBwrap))
throw new Error('bwrap path must be absolute.');
@@ -51,7 +54,7 @@ export async function executeBwrap(
payload,
[
path.dirname(relay),
path.dirname(node),
path.dirname(interpreter),
path.dirname(bwrap),
...(inputBridge ? [path.dirname(inputBridge)] : []),
],
@@ -96,7 +99,7 @@ export async function executeBwrap(
if (network === 'closed') bwrapArgs.push('--unshare-net');
bwrapArgs.push('--chdir', cwd, '--', executable, ...args);
return {
executable: node,
executable: interpreter,
args: [
relay,
String(process.pid),
@@ -36,8 +36,6 @@ export async function writeSandboxFile(
const handle = await executeSandbox(
policy,
{
// 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,
@@ -16,6 +16,7 @@ import {
writeFileSync,
} from 'node:fs';
import os from 'node:os';
import * as runtimeInfo from '../utils/runtime-info.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -239,6 +240,13 @@ describe('Landlock execution adapter', () => {
it('launches the helper through the relay with read and write grants', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
const interpreter = path.join(policy.installation, 'bun', 'bin', 'bun');
mkdirSync(path.dirname(interpreter), { recursive: true });
writeFileSync(interpreter, 'fixture');
vi.spyOn(runtimeInfo, 'getJsInterpreterCommand').mockReturnValue({
command: interpreter,
runtime: 'bun',
});
let relayLaunch!: ProcessLaunch;
executeSandboxRelay.mockImplementation(
async (_policy, _payload, _roots, createLaunch) => {
@@ -276,6 +284,10 @@ describe('Landlock execution adapter', () => {
new AbortController().signal,
);
expect(relayLaunch.executable).toBe(realpathSync(interpreter));
expect(executeSandboxRelay.mock.calls[0][2]).toContain(
path.dirname(realpathSync(interpreter)),
);
expect(relayLaunch.env).toEqual({
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8',
@@ -6,6 +6,7 @@
import { realpathSync } from 'node:fs';
import path from 'node:path';
import { getJsInterpreterCommand } from '../utils/runtime-info.js';
import type {
ProcessLaunch,
ShellExecutionConfig,
@@ -110,14 +111,16 @@ export async function executeLandlock(
const inputBridge = payload.inheritStdin
? resolveStdinBridge(policy.landlockPath)
: undefined;
const node = realpathSync(process.execPath);
const interpreter = realpathSync(
getJsInterpreterCommand(import.meta.url).command,
);
return executeSandboxRelay(
policy,
payload,
[
path.dirname(runner),
path.dirname(relay),
path.dirname(node),
path.dirname(interpreter),
...(inputBridge ? [path.dirname(inputBridge)] : []),
],
({
@@ -146,7 +149,7 @@ export async function executeLandlock(
if (filesystem === 'workspace-write') runnerArgs.push('--rw', workspace);
runnerArgs.push('--', executable, ...args);
return {
executable: node,
executable: interpreter,
args: [
relay,
String(process.pid),
@@ -16,6 +16,7 @@ const fake = vi.hoisted(() => {
pid: 987654321,
fd: 987654322,
retryWrites: false,
processRows: '',
captureWrite: false,
writeCallback: undefined as
| undefined
@@ -67,6 +68,13 @@ vi.mock('node:module', async (importOriginal) => {
};
});
vi.mock('node:child_process', () => ({
spawnSync: (command: string) => {
if (command !== 'ps') throw new Error(`Blocked command: ${command}`);
return { status: 0, stdout: fake.processRows };
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
@@ -110,6 +118,7 @@ beforeEach(() => {
fake.nativeExit = undefined;
fake.readCallback = undefined;
fake.retryWrites = false;
fake.processRows = '';
fake.captureWrite = false;
fake.writeCallback = undefined;
});
@@ -604,19 +613,40 @@ it
'exit-during-kill-group',
'still-live-after-fd-close',
])(
'preserves group fallback after %s without signalling a reaped leader',
'preserves owned-child fallback after %s without signalling a reaped leader',
async (mode) => {
vi.useFakeTimers();
let descendantAlive = true;
const childPid = fake.pid + 1;
const unrelatedPid = fake.pid + 2;
const scope =
process.platform === 'linux' || process.platform === 'darwin'
? `${fake.pid} `
: '';
const row = (pid: number, parent: number, started: string) =>
`${pid} ${parent} ${scope}${started}`;
fake.processRows = [
row(fake.pid, 1, 'old-root'),
row(childPid, fake.pid, 'old-child'),
].join('\n');
const reapLeader = () => {
fake.nativeExit!(0, 15);
fake.processRows = [
row(fake.pid, 1, 'recycled-root'),
row(childPid, 1, 'old-child'),
row(unrelatedPid, fake.pid, 'new-child'),
].join('\n');
};
const kill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (Math.abs(pid) !== fake.pid) throw new Error('Blocked unknown PID');
if (pid === -fake.pid && signal === 'SIGKILL') descendantAlive = false;
if (![fake.pid, -fake.pid, childPid, unrelatedPid].includes(pid))
throw new Error('Blocked unknown PID');
if (pid === childPid && signal === 'SIGKILL') descendantAlive = false;
if (
pid === -fake.pid &&
((mode === 'exit-during-term-group' && signal === 'SIGTERM') ||
(mode === 'exit-during-kill-group' && signal === 'SIGKILL'))
) {
fake.nativeExit!(0, 15);
reapLeader();
}
return true;
});
@@ -631,19 +661,21 @@ it
pty.write(Buffer.alloc(8192));
expect(kill.mock.calls).toEqual(
mode === 'exit-during-term-group'
? [[-fake.pid, 'SIGTERM']]
? [
[-fake.pid, 'SIGTERM'],
[childPid, 'SIGTERM'],
]
: [
[-fake.pid, 'SIGTERM'],
[fake.pid, 'SIGTERM'],
[childPid, 'SIGTERM'],
],
);
expect(vi.getTimerCount()).toBe(1);
if (mode === 'exit-before-fallback') {
fake.nativeExit!(0, 15);
}
if (mode === 'exit-before-fallback') reapLeader();
// Native leader exit does not prove TERM-resistant descendants exited.
expect(descendantAlive).toBe(true);
// Closing the fd and refunding budgets must also preserve group fallback.
// Closing the fd and refunding budgets must preserve owned-child cleanup.
fake.readCallback!(
Object.assign(new Error('mock EIO'), { code: 'EIO' }),
0,
@@ -668,9 +700,16 @@ it
? [
[-fake.pid, 'SIGKILL'],
[fake.pid, 'SIGKILL'],
[childPid, 'SIGKILL'],
]
: [[-fake.pid, 'SIGKILL']],
: mode === 'exit-during-kill-group'
? [
[-fake.pid, 'SIGKILL'],
[childPid, 'SIGKILL'],
]
: [[childPid, 'SIGKILL']],
);
expect(kill.mock.calls.some(([pid]) => pid === unrelatedPid)).toBe(false);
expect(vi.getTimerCount()).toBe(0);
expect(descendantAlive).toBe(false);
const signals = kill.mock.calls.slice();
+5 -3
View File
@@ -559,12 +559,13 @@ describe.skipIf(!POSIX)('bounded pause hold and resource limits', () => {
it('parks reads at the per-pty hold cap: the child stays alive and the stream resumes byte-exact', async () => {
const LIMIT = 128 * 1024;
const LINES = 200_000;
// Control terminal conversion while checking both literal CRLF and LF.
const pty = track(
spawn(
'/bin/sh',
[
'-c',
`awk 'BEGIN{for(i=0;i<${LINES};i++)print "P" i}'; echo FLOOD_DONE; read x; echo GOT_$x`,
`stty -onlcr && printf 'RAW_CRLF\\r\\n' && awk 'BEGIN{for(i=0;i<${LINES};i++)print "P" i}' && echo FLOOD_DONE && read x && echo GOT_$x`,
],
{ limits: { pausedHoldBytes: LIMIT } },
),
@@ -608,8 +609,9 @@ describe.skipIf(!POSIX)('bounded pause hold and resource limits', () => {
true,
);
const expected =
Array.from({ length: LINES }, (_, i) => `P${i}`).join('\r\n') +
'\r\nFLOOD_DONE\r\n';
'RAW_CRLF\r\n' +
Array.from({ length: LINES }, (_, i) => `P${i}`).join('\n') +
'\nFLOOD_DONE\n';
expect(output).toBe(expected);
pty.write('ok\n');
expect(await waitFor(() => output.includes('GOT_ok'))).toBe(true);
+6 -4
View File
@@ -914,10 +914,12 @@ export class BunPtyProcess extends EventEmitter {
// while the leader is unreaped, so its session and tty still name it.
const tree = collectPtyProcessTree(this.pid);
const signalTree = (sig: NodeJS.Signals, owned: PtyProcessTree): void => {
try {
process.kill(-this.pid, sig);
} catch {
// Process group already gone.
if (!this.exited) {
try {
process.kill(-this.pid, sig);
} catch {
// Process group already gone.
}
}
for (const pid of owned.pids) {
// A reaped leader's pid may already belong to another process.
+22 -4
View File
@@ -70,10 +70,14 @@ if (!skipBuild) {
cwd: sourceRoot,
stdio: 'inherit',
});
execFileSync(process.execPath, [npm, 'run', 'prepare:package'], {
cwd: sourceRoot,
stdio: 'inherit',
});
execFileSync(
process.execPath,
[npm, 'run', 'prepare:package', '--', '--host-runtime'],
{
cwd: sourceRoot,
stdio: 'inherit',
},
);
}
const distDir = path.join(sourceRoot, 'dist');
@@ -98,6 +102,20 @@ try {
fs.writeFileSync(path.join(packageRoot, '.gitkeep'), '');
fs.mkdirSync(binDir, { recursive: true });
copyDirectory(distDir, libDir);
// --skip-build can reuse an npm-prepared dist with all POSIX architectures.
// Desktop dependency scans must only encounter the selected target's library.
const opentuiScope = path.join(libDir, 'opentui-assets', '@opentui');
const nativePackage = `core-${target}`;
if (fs.existsSync(opentuiScope)) {
for (const entry of fs.readdirSync(opentuiScope)) {
if (entry.startsWith('core-') && entry !== nativePackage) {
fs.rmSync(path.join(opentuiScope, entry), {
recursive: true,
force: true,
});
}
}
}
stageNodePty(target);
await installNodeRuntime(nodeDir, target);
writeLaunchers(target);
+86
View File
@@ -989,6 +989,40 @@ function testRuntimeTargetMappings() {
);
}
const assetFilter =
/const opentuiScope =[\s\S]*?(?=\n {2}stageNodePty\(target\);)/.exec(
prepareRuntime,
)?.[0];
assert.ok(
assetFilter,
'the final copied runtime must filter OpenTUI native targets',
);
for (const target of supported) {
const libDir = path.join(root, 'opentui-targets', target);
const scope = path.join(libDir, 'opentui-assets', '@opentui');
for (const candidate of supported) {
const directory = path.join(scope, `core-${candidate}`);
fs.mkdirSync(directory, { recursive: true });
const library = candidate.startsWith('darwin')
? 'libopentui.dylib'
: candidate.startsWith('win32')
? 'opentui.dll'
: 'libopentui.so';
fs.writeFileSync(path.join(directory, library), candidate);
}
fs.mkdirSync(path.join(scope, 'core'), { recursive: true });
fs.writeFileSync(path.join(scope, 'core', 'parser.worker.js'), 'common');
// Run the actual preparation block against disk for every release target;
// the full cross-build preparation fixture below verifies its call site.
vm.runInNewContext(assetFilter, { fs, path, libDir, target });
assert.deepEqual(fs.readdirSync(scope).sort(), ['core', `core-${target}`]);
assert.equal(
fs.readFileSync(path.join(scope, 'core', 'parser.worker.js'), 'utf8'),
'common',
);
assert.equal(fs.readdirSync(path.join(scope, `core-${target}`)).length, 1);
}
const runtimeSmokeChecks = fs.readFileSync(
path.join(packageDir, 'scripts', 'runtime-smoke-checks.js'),
'utf8',
@@ -1116,6 +1150,28 @@ function testRuntimePreparation(directory) {
);
fs.mkdirSync(path.dirname(sourceRipgrep), { recursive: true });
fs.writeFileSync(sourceRipgrep, 'test ripgrep');
const sourceOpenTui = path.join(
sourceRoot,
'dist',
'opentui-assets',
'@opentui',
);
for (const target of [
'darwin-arm64',
'darwin-x64',
'linux-arm64',
'linux-x64',
'win32-x64',
]) {
const directory = path.join(sourceOpenTui, `core-${target}`);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, 'native-library'), target);
}
fs.mkdirSync(path.join(sourceOpenTui, 'core'), { recursive: true });
fs.writeFileSync(
path.join(sourceOpenTui, 'core', 'parser.worker.js'),
'shared-worker',
);
// Staging installs the target's pinned packages into a throwaway prefix
// instead of reading a host node_modules that cannot hold a cross-built
// target's addon (#11872), so npm is stubbed: it records the argv it is
@@ -1202,6 +1258,36 @@ globalThis.fetch = async (url) => {
assert.ok(
fs.existsSync(path.join(runtimeDir, 'qwen-code', 'checksums.json')),
);
const stagedOpenTui = path.join(
runtimeDir,
'qwen-code',
'lib',
'opentui-assets',
'@opentui',
);
assert.deepEqual(fs.readdirSync(stagedOpenTui).sort(), [
'core',
'core-darwin-x64',
]);
assert.equal(
fs.readFileSync(
path.join(stagedOpenTui, 'core-darwin-x64', 'native-library'),
'utf8',
),
'darwin-x64',
);
assert.equal(
fs.readFileSync(
path.join(stagedOpenTui, 'core', 'parser.worker.js'),
'utf8',
),
'shared-worker',
);
assert.equal(
fs.readdirSync(sourceOpenTui).length,
6,
'filter only the owned copied runtime, not the reusable dist',
);
// The Web Terminal resolves its PTY backend from lib/, so the runtime has to
// carry the wrapper and this target's prebuild under lib/node_modules
@@ -126,6 +126,29 @@ function pruneBundledRipgrep() {
);
}
function pruneBundledOpenTui() {
if (process.env.UNIVERSAL_BUILD === 'true' || !process.env.VSCODE_TARGET) {
return;
}
const expected = getExpectedRipgrepDirName();
if (!expected) return;
const [arch, platform] = expected.split('-');
const scope = path.join(bundledCliDir, 'opentui-assets', '@opentui');
if (!fs.existsSync(scope)) return;
for (const entry of fs.readdirSync(scope, { withFileTypes: true })) {
if (
entry.isDirectory() &&
entry.name.startsWith('core-') &&
entry.name !== `core-${platform}-${arch}`
) {
fs.rmSync(path.join(scope, entry.name), {
recursive: true,
force: true,
});
}
}
}
function removeSelfReferenceFromNodeModules() {
if (process.platform !== 'win32') return;
@@ -187,7 +210,11 @@ function main() {
run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot });
console.log('[prepackage] Preparing root dist/ package metadata...');
run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot });
run(
npm,
['--prefix', repoRoot, 'run', 'prepare:package', '--', '--host-runtime'],
{ cwd: repoRoot },
);
console.log('[prepackage] Preparing web-shell dist/ package metadata...');
run(
@@ -247,6 +274,7 @@ function main() {
removeSelfReferenceFromNodeModules();
pruneBundledRipgrep();
pruneBundledOpenTui();
}
main();
+2 -45
View File
@@ -15,6 +15,7 @@ import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
import { stageOpenTuiPackages } from './opentui-package-assets.js';
import {
TARGET_CLIPBOARD_PACKAGE,
standaloneArchiveName,
@@ -143,7 +144,7 @@ async function main() {
// Node packaging must not install them (nor fail on a missing lockfile
// entry) at all.
const opentuiModulesDir = flavors.includes('bun')
? stageOpenTuiPackages(runtimeDir)
? stageOpenTuiPackages(rootDir, runtimeDir, OPENTUI_PLATFORM_PACKAGES)
: undefined;
for (const flavor of flavors) {
@@ -345,50 +346,6 @@ function stageNativeModules(runtimeDir) {
return path.join(installDir, 'node_modules');
}
// Temporary OpenTUI preview: the bundled OpenTUI backend resolves its native
// render library at runtime via `import('@opentui/core-<platform>-<arch>')`,
// so each standalone archive must ship the matching platform package(s).
function readOpenTuiPackageSpecs() {
return OPENTUI_PLATFORM_PACKAGES.map((packageName) => {
const version = readPnpmLockedVersion(packageName);
if (!version) {
fail(`OpenTUI platform package version is not locked for ${packageName}`);
}
return `${packageName}@${version}`;
});
}
function stageOpenTuiPackages(runtimeDir) {
const installDir = path.join(runtimeDir, 'opentui-modules');
fs.mkdirSync(installDir, { recursive: true });
console.log('Staging standalone OpenTUI native packages');
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
fail('npm_execpath is unavailable; run package:standalone:release via npm');
}
execFileSync(
process.execPath,
[
npmExecPath,
'install',
'--prefix',
installDir,
'--package-lock=false',
'--no-save',
'--ignore-scripts',
'--force',
'--no-audit',
'--no-fund',
...readOpenTuiPackageSpecs(),
],
{
cwd: rootDir,
stdio: 'inherit',
},
);
return path.join(installDir, 'node_modules');
}
async function downloadFile(url, destination, { fetchImpl = fetch } = {}) {
console.log(`Downloading ${url}`);
const response = await fetchImpl(url, {
+130
View File
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';
export const NPM_BUN_OPTIONAL_DEPENDENCIES = Object.fromEntries(
[
'@oven/bun-darwin-aarch64',
'@oven/bun-darwin-x64',
'@oven/bun-linux-aarch64',
'@oven/bun-linux-x64',
].map((name) => [name, '1.3.14']),
);
export const NPM_OPENTUI_NATIVE_ASSETS = {
'@opentui/core-darwin-arm64': 'libopentui.dylib',
'@opentui/core-darwin-x64': 'libopentui.dylib',
'@opentui/core-linux-arm64': 'libopentui.so',
'@opentui/core-linux-x64': 'libopentui.so',
};
export const OPENTUI_COMMON_ASSETS = [
'@opentui/core/parser.worker.js',
'@opentui/core/assets/javascript/highlights.scm',
'@opentui/core/assets/javascript/tree-sitter-javascript.wasm',
'@opentui/core/assets/typescript/highlights.scm',
'@opentui/core/assets/typescript/tree-sitter-typescript.wasm',
'@opentui/core/assets/markdown/highlights.scm',
'@opentui/core/assets/markdown/injections.scm',
'@opentui/core/assets/markdown/tree-sitter-markdown.wasm',
'@opentui/core/assets/markdown_inline/highlights.scm',
'@opentui/core/assets/markdown_inline/tree-sitter-markdown_inline.wasm',
'@opentui/core/assets/zig/highlights.scm',
'@opentui/core/assets/zig/tree-sitter-zig.wasm',
'web-tree-sitter/tree-sitter.wasm',
];
function lockedVersions(rootDir, packageNames) {
const keys = Object.keys(
parseYaml(fs.readFileSync(path.join(rootDir, 'pnpm-lock.yaml'), 'utf8'))
?.packages ?? {},
);
return Object.fromEntries(
packageNames.map((name) => {
const versions = keys
.filter((key) => key.startsWith(`${name}@`))
.map((key) => key.slice(name.length + 1));
if (versions.length !== 1) {
throw new Error(
`OpenTUI platform package version is not locked for ${name}`,
);
}
return [name, versions[0]];
}),
);
}
export function stageOpenTuiPackages(rootDir, runtimeDir, packageNames) {
const installDir = path.join(runtimeDir, 'opentui-modules');
fs.mkdirSync(installDir, { recursive: true });
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error('npm_execpath is unavailable; run packaging via npm run');
}
const versions = lockedVersions(rootDir, packageNames);
execFileSync(
process.execPath,
[
npmExecPath,
'install',
'--prefix',
installDir,
'--package-lock=false',
'--no-save',
'--ignore-scripts',
'--force',
'--no-audit',
'--no-fund',
...Object.entries(versions).map(
([name, version]) => `${name}@${version}`,
),
],
{ cwd: rootDir, stdio: 'inherit' },
);
const modulesDir = path.join(installDir, 'node_modules');
for (const [name, version] of Object.entries(versions)) {
const installed = JSON.parse(
fs.readFileSync(path.join(modulesDir, name, 'package.json'), 'utf8'),
);
if (installed.name !== name || installed.version !== version) {
throw new Error(
`Staged OpenTUI package does not match ${name}@${version}`,
);
}
}
return modulesDir;
}
export function prepareNpmOpenTuiAssets(distDir, modulesDir) {
const assetRoot = path.join(distDir, 'opentui-assets');
if (modulesDir) {
for (const [name, library] of Object.entries(NPM_OPENTUI_NATIVE_ASSETS)) {
const destination = path.join(assetRoot, name, library);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(path.join(modulesDir, name, library), destination);
}
}
const required = [
...OPENTUI_COMMON_ASSETS,
...Object.entries(NPM_OPENTUI_NATIVE_ASSETS).map(
([name, library]) => `${name}/${library}`,
),
];
for (const key of required) {
let present = false;
try {
present = fs.statSync(path.join(assetRoot, key)).isFile();
} catch {
// Report the asset key instead of an absolute temporary staging path.
}
if (!present)
throw new Error(`Required npm OpenTUI asset not found: ${key}`);
}
}
+37 -14
View File
@@ -15,6 +15,13 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import semver from 'semver';
import os from 'node:os';
import {
NPM_BUN_OPTIONAL_DEPENDENCIES,
NPM_OPENTUI_NATIVE_ASSETS,
prepareNpmOpenTuiAssets,
stageOpenTuiPackages,
} from './opentui-package-assets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -39,24 +46,41 @@ export function preparePackage({
requireNativeAudioCapture = process.env
.QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD === '1',
maxPackageUnpackedBytes = DEFAULT_MAX_NPM_PACKAGE_UNPACKED_BYTES,
opentuiModulesDir,
npmDistribution = true,
} = {}) {
const distDir = path.join(rootDir, 'dist');
verifyBundleArtifacts(rootDir, distDir);
if (npmDistribution) prepareNpmOpenTuiAssets(distDir, opentuiModulesDir);
copyDocumentationFiles(rootDir, distDir);
copyLocales(rootDir, distDir);
copyExtensionExamples(rootDir, distDir);
verifyNativeAudioCapturePackage(rootDir, distDir, {
required: requireNativeAudioCapture,
});
writeDistPackageJson(rootDir, distDir);
writeDistPackageJson(rootDir, distDir, npmDistribution);
assertNoSensitivePackageScanLiterals(distDir);
assertPreparedPackageSize(distDir, maxPackageUnpackedBytes);
printPackageStructure(distDir);
}
if (isDirectRun()) {
preparePackage();
if (process.argv.includes('--host-runtime')) {
preparePackage({ npmDistribution: false });
} else {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-npm-opentui-'));
try {
const opentuiModulesDir = stageOpenTuiPackages(
defaultRootDir,
staging,
Object.keys(NPM_OPENTUI_NATIVE_ASSETS),
);
preparePackage({ opentuiModulesDir });
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
}
function isDirectRun() {
@@ -277,7 +301,7 @@ function hasFileMatching(dir, predicate) {
return false;
}
function writeDistPackageJson(rootDir, distDir) {
function writeDistPackageJson(rootDir, distDir, npmDistribution) {
console.log('Creating package.json for distribution...');
const cliEntryPath = path.join(distDir, 'cli-entry.js');
@@ -372,21 +396,20 @@ function writeDistPackageJson(rootDir, distDir) {
'web-shell',
'export-transcript-document.js',
'export-transcript-document.css',
// OpenTUI renderer runtime assets (tree-sitter grammars, parser worker,
// web-tree-sitter wasm, native render library) are intentionally NOT
// published in the npm package — a multi-megabyte tree dominated by the
// native render library — against the unpacked-size budget. An npm
// install also has no @opentui/core dependency to resolve the assets
// against: below the runtime floor (Node < 26.4.0) the renderer gate
// never selects opentui, and at or above it, QWEN_TUI_RENDERER=opentui
// selects the renderer, the boot fails on the missing asset tree, and
// a stderr warning falls back to ink on every startup. The opt-in Bun
// standalone flavor (--runtime=bun) carries the assets via
// create-standalone-package.js instead.
...(npmDistribution
? [
'opentui-assets/@opentui/core',
'opentui-assets/web-tree-sitter',
...Object.keys(NPM_OPENTUI_NATIVE_ASSETS).map(
(name) => `opentui-assets/${name}`,
),
]
: []),
],
config: rootPackageJson.config,
dependencies: {},
optionalDependencies: {
...(npmDistribution ? NPM_BUN_OPTIONAL_DEPENDENCIES : {}),
'@qwen-code/audio-capture': rootPackageJson.version,
...nodePtyPins,
'@teddyzhu/clipboard': '0.0.5',
+4 -3
View File
@@ -763,15 +763,16 @@ describe('standalone release packaging', () => {
expect(allowedBlock).not.toBeNull();
expect(npmOnlyBlock).not.toBeNull();
for (const entry of entries) {
if (entry.includes('*')) {
const topLevelEntry = entry.split('/')[0];
if (topLevelEntry.includes('*')) {
// Glob entries (today: '*.sb') are covered by
// DIST_ALLOWED_ENTRY_PATTERNS rather than a literal name.
expect(packageScript).toContain('DIST_ALLOWED_ENTRY_PATTERNS');
continue;
}
const known =
allowedBlock[1].includes(`'${entry}'`) ||
npmOnlyBlock[1].includes(`'${entry}'`);
allowedBlock[1].includes(`'${topLevelEntry}'`) ||
npmOnlyBlock[1].includes(`'${topLevelEntry}'`);
expect(known, `npm files entry '${entry}' unknown to packager`).toBe(
true,
);
@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
NPM_BUN_OPTIONAL_DEPENDENCIES,
NPM_OPENTUI_NATIVE_ASSETS,
OPENTUI_COMMON_ASSETS,
prepareNpmOpenTuiAssets,
stageOpenTuiPackages,
} from '../opentui-package-assets.js';
import { requiredOpenTuiAssetKeys } from '../../packages/cli/src/ui/opentui/opentui-assets.js';
vi.mock('node:child_process', () => ({ execFileSync: vi.fn() }));
const roots = [];
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.mocked(execFileSync).mockReset();
for (const root of roots.splice(0))
fs.rmSync(root, { recursive: true, force: true });
});
function write(file, contents) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
}
function fixture() {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-opentui-packaging-'),
);
roots.push(root);
const runtimeDir = path.join(root, 'stage');
const modules = path.join(runtimeDir, 'opentui-modules/node_modules');
const dist = path.join(root, 'dist');
const names = Object.keys(NPM_OPENTUI_NATIVE_ASSETS);
write(
path.join(root, 'pnpm-lock.yaml'),
JSON.stringify({
packages: Object.fromEntries(names.map((name) => [`${name}@0.5.10`, {}])),
}),
);
for (const [name, library] of Object.entries(NPM_OPENTUI_NATIVE_ASSETS)) {
write(
path.join(modules, name, 'package.json'),
JSON.stringify({ name, version: '0.5.10' }),
);
write(path.join(modules, name, library), name);
}
for (const key of OPENTUI_COMMON_ASSETS)
write(path.join(dist, 'opentui-assets', key), key);
return { root, runtimeDir, modules, dist, names };
}
describe('npm OpenTUI package construction', () => {
it('pins the official POSIX runtime packages to the standalone Bun cohort', () => {
const standalone = fs.readFileSync(
new URL('../build-standalone-release.js', import.meta.url),
'utf8',
);
const version = /const DEFAULT_BUN_VERSION = '([^']+)'/.exec(
standalone,
)?.[1];
expect(version).toBe('1.3.14');
expect(Object.keys(NPM_BUN_OPTIONAL_DEPENDENCIES)).toEqual([
'@oven/bun-darwin-aarch64',
'@oven/bun-darwin-x64',
'@oven/bun-linux-aarch64',
'@oven/bun-linux-x64',
]);
expect([...new Set(Object.values(NPM_BUN_OPTIONAL_DEPENDENCIES))]).toEqual([
version,
]);
});
it('stages the exact lockfile versions without lifecycle scripts', () => {
const f = fixture();
vi.stubEnv('npm_execpath', '/operator/npm-cli.js');
expect(stageOpenTuiPackages(f.root, f.runtimeDir, f.names)).toBe(f.modules);
expect(execFileSync).toHaveBeenCalledExactlyOnceWith(
process.execPath,
[
'/operator/npm-cli.js',
'install',
'--prefix',
path.join(f.runtimeDir, 'opentui-modules'),
'--package-lock=false',
'--no-save',
'--ignore-scripts',
'--force',
'--no-audit',
'--no-fund',
...f.names.map((name) => `${name}@0.5.10`),
],
{ cwd: f.root, stdio: 'inherit' },
);
});
it('rejects ambiguous lockfile versions before invoking npm', () => {
const f = fixture();
vi.stubEnv('npm_execpath', '/operator/npm-cli.js');
write(
path.join(f.root, 'pnpm-lock.yaml'),
JSON.stringify({
packages: {
[`${f.names[0]}@0.5.10`]: {},
[`${f.names[0]}@0.5.11`]: {},
},
}),
);
expect(() =>
stageOpenTuiPackages(f.root, f.runtimeDir, [f.names[0]]),
).toThrow('version is not locked');
expect(execFileSync).not.toHaveBeenCalled();
});
it('rejects a staged package that differs from the exact lockfile pin', () => {
const f = fixture();
vi.stubEnv('npm_execpath', '/operator/npm-cli.js');
write(
path.join(f.modules, f.names[0], 'package.json'),
JSON.stringify({ name: f.names[0], version: '0.5.11' }),
);
expect(() => stageOpenTuiPackages(f.root, f.runtimeDir, f.names)).toThrow(
'does not match',
);
});
it('copies all four natives into a complete root matching the real runtime keys', () => {
const f = fixture();
prepareNpmOpenTuiAssets(f.dist, f.modules);
for (const platform of ['darwin', 'linux']) {
for (const arch of ['arm64', 'x64']) {
const required = requiredOpenTuiAssetKeys(platform, arch, {});
expect(required).toHaveLength(14);
for (const key of required) {
expect(
fs.statSync(path.join(f.dist, 'opentui-assets', key)).isFile(),
key,
).toBe(true);
}
expect(required.slice(1)).toEqual(OPENTUI_COMMON_ASSETS);
}
}
for (const [name, library] of Object.entries(NPM_OPENTUI_NATIVE_ASSETS)) {
expect(
fs.readFileSync(
path.join(f.dist, 'opentui-assets', name, library),
'utf8',
),
).toBe(name);
}
});
it('does not accept a directory where the parser worker should be', () => {
const f = fixture();
const worker = path.join(
f.dist,
'opentui-assets',
OPENTUI_COMMON_ASSETS[0],
);
fs.rmSync(worker);
fs.mkdirSync(worker);
expect(() => prepareNpmOpenTuiAssets(f.dist, f.modules)).toThrow(
OPENTUI_COMMON_ASSETS[0],
);
});
});
+242
View File
@@ -17,6 +17,8 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import {
@@ -25,6 +27,11 @@ import {
} from '../copy_bundle_assets.js';
import { copyFiles } from '../copy_files.js';
import { preparePackage } from '../prepare-package.js';
import {
NPM_BUN_OPTIONAL_DEPENDENCIES,
NPM_OPENTUI_NATIVE_ASSETS,
OPENTUI_COMMON_ASSETS,
} from '../opentui-package-assets.js';
import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js';
const realReadFileSync = fs.readFileSync;
@@ -655,6 +662,7 @@ describe('package asset scripts', () => {
stubConsole();
copyBundleAssets({ root: rootDir });
createOpenTuiArtifacts(rootDir);
preparePackage({ rootDir, requireNativeAudioCapture: false });
expect(
@@ -1043,6 +1051,7 @@ describe('package asset scripts', () => {
expect(distPackageJson.bundledDependencies).toBeUndefined();
expect(distPackageJson.optionalDependencies).toMatchObject({
'@qwen-code/audio-capture': rootPackageJson.version,
...NPM_BUN_OPTIONAL_DEPENDENCIES,
});
expect(distPackageJson.optionalDependencies.sharp).toBe('0.35.4');
@@ -1150,6 +1159,227 @@ describe('package asset scripts', () => {
).toThrow(/installed 0\.35\.4, packages\/core declares \^0\.34\.0/);
});
it('publishes a complete POSIX asset tree and leaves host-only dist assets intact', () => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
stubConsole();
const windowsAsset =
'dist/opentui-assets/@opentui/core-win32-x64/opentui.dll';
writeFile(rootDir, windowsAsset, 'windows-host-library');
preparePackage({ rootDir, requireNativeAudioCapture: false });
const published = JSON.parse(
readFileSync(path.join(rootDir, 'dist/package.json'), 'utf8'),
);
expect(published.files).toEqual(
expect.arrayContaining([
'opentui-assets/@opentui/core',
'opentui-assets/web-tree-sitter',
...Object.keys(NPM_OPENTUI_NATIVE_ASSETS).map(
(name) => `opentui-assets/${name}`,
),
]),
);
expect(published.files).not.toContain('opentui-assets');
expect(published.files).not.toContain(
'opentui-assets/@opentui/core-win32-x64',
);
expect(readFileSync(path.join(rootDir, windowsAsset), 'utf8')).toBe(
'windows-host-library',
);
expect(published.scripts).toBeUndefined();
});
it.each([
...OPENTUI_COMMON_ASSETS,
...Object.entries(NPM_OPENTUI_NATIVE_ASSETS).map(
([name, library]) => `${name}/${library}`,
),
])('rejects npm packaging without %s', (key) => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
stubConsole();
rmSync(path.join(rootDir, 'dist/opentui-assets', key));
expect(() =>
preparePackage({ rootDir, requireNativeAudioCapture: false }),
).toThrow(`Required npm OpenTUI asset not found: ${key}`);
});
it('prepares desktop metadata without universal assets or Bun dependencies', () => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
stubConsole();
rmSync(path.join(rootDir, 'dist/opentui-assets'), { recursive: true });
writeFile(
rootDir,
'dist/opentui-assets/@opentui/core-win32-x64/opentui.dll',
'host-native',
);
preparePackage({
rootDir,
requireNativeAudioCapture: false,
npmDistribution: false,
});
const published = JSON.parse(
readFileSync(path.join(rootDir, 'dist/package.json'), 'utf8'),
);
expect(
Object.keys(published.optionalDependencies).some((name) =>
name.startsWith('@oven/'),
),
).toBe(false);
expect(
readFileSync(
path.join(
rootDir,
'dist/opentui-assets/@opentui/core-win32-x64/opentui.dll',
),
'utf8',
),
).toBe('host-native');
expect(
existsSync(
path.join(rootDir, 'dist/opentui-assets/@opentui/core-linux-x64'),
),
).toBe(false);
});
it.each([
['darwin-x64', false],
['win32-x64', false],
['', true],
['', false],
['unknown-x64', false],
])(
'keeps VSIX metadata and copied assets scoped to target=%s universal=%s',
(target, universal) => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
const extension = 'packages/vscode-ide-companion';
writeFile(rootDir, `${extension}/package.json`, '{"type":"module"}');
for (const script of ['prepackage.js', 'copy-bundled-cli.js']) {
writeFile(
rootDir,
`${extension}/scripts/${script}`,
readFileSync(
new URL(`../../${extension}/scripts/${script}`, import.meta.url),
'utf8',
),
);
}
writeFile(
rootDir,
'dist/opentui-assets/@opentui/core-win32-x64/opentui.dll',
'windows-native',
);
writeFile(
rootDir,
`${extension}/dist/qwen-cli/node_modules/existing-runtime/index.js`,
'keep-existing-dependency',
);
const preload = path.join(rootDir, 'prepackage-fixture.mjs');
const callsFile = path.join(rootDir, 'calls.jsonl');
const bundled = path.join(rootDir, extension, 'dist', 'qwen-cli');
// Execute the real orchestration, metadata builder, and copy script.
// Only npm build/install processes are replaced; no downloads occur.
writeFileSync(
preload,
`
import childProcess from 'node:child_process';
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { preparePackage } from ${JSON.stringify(new URL('../prepare-package.js', import.meta.url).href)};
const realSpawn = childProcess.spawnSync;
childProcess.spawnSync = (command, args, options) => {
if (command === 'node') {
return realSpawn(process.execPath, args, { ...options, shell: false });
}
if (args.includes('prepare:package')) {
preparePackage({
rootDir: ${JSON.stringify(rootDir)},
requireNativeAudioCapture: false,
npmDistribution: !args.includes('--host-runtime'),
});
}
const manifest = args.includes('install')
? JSON.parse(fs.readFileSync(${JSON.stringify(path.join(bundled, 'package.json'))}, 'utf8'))
: undefined;
fs.appendFileSync(${JSON.stringify(callsFile)}, JSON.stringify({ args, manifest }) + '\\n');
return { status: 0 };
};
syncBuiltinESMExports();
`,
);
const home = path.join(rootDir, 'home');
mkdirSync(home);
const result = spawnSync(
process.execPath,
[
'--import',
pathToFileURL(preload).href,
path.join(rootDir, extension, 'scripts', 'prepackage.js'),
],
{
cwd: rootDir,
encoding: 'utf8',
timeout: 30_000,
env: {
...process.env,
HOME: home,
USERPROFILE: home,
QWEN_HOME: path.join(home, '.qwen'),
npm_config_cache: path.join(rootDir, 'npm-cache'),
npm_config_prefix: path.join(rootDir, 'npm-prefix'),
NODE_OPTIONS: '',
UNIVERSAL_BUILD: String(universal),
VSCODE_TARGET: target,
},
},
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr || result.stdout).toBe(0);
const calls = readFileSync(callsFile, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const install = calls.find(({ args }) => args.includes('install'));
expect(
Object.keys(install.manifest.optionalDependencies).some((name) =>
name.startsWith('@oven/'),
),
).toBe(false);
expect(install.manifest.optionalDependencies).toMatchObject({
'@qwen-code/audio-capture': '0.17.0',
'@teddyzhu/clipboard': '0.0.5',
sharp: '0.35.4',
});
expect(install.args.includes('--omit=optional')).toBe(universal);
expect(install.args).toContain('--omit=dev');
const scope = 'opentui-assets/@opentui';
const sourceEntries = readdirSync(
path.join(rootDir, 'dist', scope),
).sort();
const expectedEntries =
target === 'darwin-x64' || target === 'win32-x64'
? ['core', `core-${target}`]
: sourceEntries;
expect(readdirSync(path.join(bundled, scope)).sort()).toEqual(
expectedEntries,
);
expect(sourceEntries).toHaveLength(6);
for (const key of OPENTUI_COMMON_ASSETS) {
expect(
readFileSync(path.join(bundled, 'opentui-assets', key), 'utf8'),
).toBe(key);
}
expect(
readFileSync(
path.join(bundled, 'node_modules/existing-runtime/index.js'),
'utf8',
),
).toBe('keep-existing-dependency');
},
);
it('omits browser MCP install hooks and deps from the prepared dist package', () => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
@@ -1590,7 +1820,19 @@ describe('package asset scripts', () => {
return rootDir;
}
function createOpenTuiArtifacts(rootDir) {
for (const key of [
...OPENTUI_COMMON_ASSETS,
...Object.entries(NPM_OPENTUI_NATIVE_ASSETS).map(
([name, library]) => `${name}/${library}`,
),
]) {
writeFile(rootDir, `dist/opentui-assets/${key}`, key);
}
}
function createBundleArtifacts(rootDir) {
createOpenTuiArtifacts(rootDir);
writeFile(rootDir, 'dist/mem0/main.js', '');
writeFile(rootDir, 'dist/mem0/write-confirmation.js', '');
for (const file of [